From ab3e50305b49953b0d75e439a0610d01cae8c822 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 22:40:42 +0000 Subject: [PATCH 1/2] Feature/add trading journal with calendar view Add a Tradezella-inspired trading journal feature to the Portfolio section. This provides a monthly calendar view showing daily P&L (total portfolio change as primary, realized P&L as secondary), color-coded days (green/red/grey), activity counts, trading statistics, and a full day detail dialog with activities list and journal notes. Key changes: - Database: JournalEntry model with Prisma migration - Backend: Journal module with REST API (GET/PUT/DELETE /api/v1/journal) - Frontend: GfJournalCalendarComponent (UI library), journal page with day detail dialog, new "Journal" tab in Portfolio section - Shared: Permissions, DTOs, response interfaces, route definitions https://claude.ai/code/session_01XEieh1hHrXc1fcbnA7oBHn --- apps/api/src/app/app.module.ts | 2 + .../api/src/app/journal/journal.controller.ts | 216 ++++++++++++++++++ apps/api/src/app/journal/journal.module.ts | 16 ++ apps/api/src/app/journal/journal.service.ts | 161 +++++++++++++ .../journal-day-dialog.component.html | 123 ++++++++++ .../journal-day-dialog.component.scss | 65 ++++++ .../journal-day-dialog.component.ts | 187 +++++++++++++++ .../journal/journal-page.component.ts | 130 +++++++++++ .../pages/portfolio/journal/journal-page.html | 21 ++ .../portfolio/journal/journal-page.routes.ts | 10 + .../pages/portfolio/journal/journal-page.scss | 3 + .../portfolio/portfolio-page.component.ts | 7 + .../pages/portfolio/portfolio-page.routes.ts | 5 + .../create-or-update-journal-entry.dto.ts | 10 + libs/common/src/lib/dtos/index.ts | 2 + libs/common/src/lib/interfaces/index.ts | 10 + .../responses/journal-response.interface.ts | 30 +++ libs/common/src/lib/permissions.ts | 9 + libs/common/src/lib/routes/routes.ts | 5 + libs/ui/src/lib/journal-calendar/index.ts | 1 + .../journal-calendar.component.html | 127 ++++++++++ .../journal-calendar.component.scss | 185 +++++++++++++++ .../journal-calendar.component.ts | 213 +++++++++++++++++ libs/ui/src/lib/services/data.service.ts | 22 ++ .../migration.sql | 23 ++ prisma/schema.prisma | 37 ++- 26 files changed, 1609 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/app/journal/journal.controller.ts create mode 100644 apps/api/src/app/journal/journal.module.ts create mode 100644 apps/api/src/app/journal/journal.service.ts create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-page.component.ts create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-page.html create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts create mode 100644 apps/client/src/app/pages/portfolio/journal/journal-page.scss create mode 100644 libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts create mode 100644 libs/common/src/lib/interfaces/responses/journal-response.interface.ts create mode 100644 libs/ui/src/lib/journal-calendar/index.ts create mode 100644 libs/ui/src/lib/journal-calendar/journal-calendar.component.html create mode 100644 libs/ui/src/lib/journal-calendar/journal-calendar.component.scss create mode 100644 libs/ui/src/lib/journal-calendar/journal-calendar.component.ts create mode 100644 prisma/migrations/20260214000000_added_journal_entry/migration.sql 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..36dd5ea12 --- /dev/null +++ b/apps/api/src/app/journal/journal.controller.ts @@ -0,0 +1,216 @@ +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 { 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 } 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() + @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)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + const startDate = startOfMonth(new Date(yearNum, monthNum)); + const endDate = endOfMonth(new Date(yearNum, monthNum)); + + const [journalEntries, performanceResponse, activitiesResponse] = + await Promise.all([ + this.journalService.getJournalEntries({ + startDate, + endDate, + userId + }), + this.portfolioService.getPerformance({ + dateRange: `${yearNum}`, + filters: [], + impersonationId: undefined, + userId + }), + this.orderService.getOrders({ + startDate, + endDate, + userId, + userCurrency: this.request.user.settings?.settings?.baseCurrency, + includeDrafts: false, + 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 + const sortedDates = Array.from(daysMap.keys()).sort(); + let previousNetPerformance = 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; + } + } + + for (const dateKey of sortedDates) { + const day = daysMap.get(dateKey); + const currentNetPerformance = day.netPerformance; + day.netPerformance = currentNetPerformance - previousNetPerformance; + previousNetPerformance = currentNetPerformance; + } + + // Count activities per day and calculate realized profit + 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++; + + if ( + activity.type === 'SELL' || + 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') + @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..69ed8f622 --- /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) { +
+
Realized P&L
+ +
+ } + +
+
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..8068cfa81 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss @@ -0,0 +1,65 @@ +: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; + } +} 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..d83ad83dd --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts @@ -0,0 +1,187 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { Activity, User } from '@ghostfolio/common/interfaces'; +import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +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 { 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, + GfActivitiesTableComponent, + GfDialogFooterComponent, + GfDialogHeaderComponent, + GfValueComponent, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule + ], + 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 isLoadingActivities = true; + public isLoadingNote = true; + public isSavingNote = false; + public note = ''; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData, + private dataService: DataService, + public dialogRef: MatDialogRef, + private userService: UserService + ) {} + + public ngOnInit() { + this.date = this.data.date; + this.dateLabel = format(parseISO(this.date), 'EEEE, MMMM d, yyyy'); + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + this.changeDetectorRef.markForCheck(); + } + }); + + 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.isSavingNote = false; + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.isSavingNote = false; + this.changeDetectorRef.markForCheck(); + } + }); + } else { + this.dataService + .deleteJournalEntry({ date: this.date }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.isSavingNote = false; + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.isSavingNote = false; + this.changeDetectorRef.markForCheck(); + } + }); + } + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private loadActivities() { + this.isLoadingActivities = true; + + this.dataService + .fetchActivities({ + filters: [], + skip: 0, + take: 50, + 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.isLoadingNote = false; + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.note = ''; + 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..d1eb2263e --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts @@ -0,0 +1,130 @@ +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', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + 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..7a97f7256 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts @@ -0,0 +1,10 @@ +import { Routes } from '@angular/router'; + +import { JournalPageComponent } from './journal-page.component'; + +export const routes: Routes = [ + { + component: JournalPageComponent, + path: '' + } +]; 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..627291664 --- /dev/null +++ b/libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts @@ -0,0 +1,10 @@ +import { IsISO8601, IsString, MaxLength } from 'class-validator'; + +export class CreateOrUpdateJournalEntryDto { + @IsISO8601() + date: string; + + @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..21f5e64f3 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', @@ -54,6 +56,7 @@ export const permissions = { updateAccount: 'updateAccount', updateAccess: 'updateAccess', updateAuthDevice: 'updateAuthDevice', + updateJournalEntry: 'updateJournalEntry', updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateOrder: 'updateOrder', @@ -74,8 +77,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, @@ -100,6 +105,7 @@ export function getPermissions(aRole: Role): string[] { permissions.updateAccount, permissions.updateAccess, permissions.updateAuthDevice, + permissions.updateJournalEntry, permissions.updateMarketData, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder, @@ -125,6 +131,7 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, + permissions.createJournalEntry, permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, permissions.createOwnTag, @@ -133,6 +140,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteAuthDevice, + permissions.deleteJournalEntry, permissions.deleteOrder, permissions.deleteWatchlistItem, permissions.readAiPrompt, @@ -142,6 +150,7 @@ export function getPermissions(aRole: Role): string[] { 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..e96c00765 --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.html @@ -0,0 +1,127 @@ +
+ +
+ +

{{ 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 }} + {{ day.data.activitiesCount === 1 ? 'trade' : 'trades' }} +
+ } + @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..b30785e3e --- /dev/null +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts @@ -0,0 +1,213 @@ +import { + JournalCalendarDataItem, + JournalStats +} from '@ghostfolio/common/interfaces'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output +} 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, + 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 = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + public get monthLabel(): string { + return format(new Date(this.year, this.month - 1), 'MMMM yyyy'); + } + + 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() { + this.buildCalendar(); + } + + 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 === 0 || 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 buildCalendar() { + const currentDate = new Date(this.year, this.month - 1); + const monthStart = startOfMonth(currentDate); + const monthEnd = endOfMonth(currentDate); + const today = new Date(); + + 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: + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + }); + } + + // 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..acee1281a 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}`, { 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? From 6f01b52194abd8edf5b3a25ad018ee8577ef8d26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 23:22:14 +0000 Subject: [PATCH 2/2] Fix critical and high severity issues from code review - Fix [class] binding overwriting CSS classes (use [ngClass] instead) - Fix activities fetch returning wrong data for older dates (use year range) - Add @HasPermission(readJournalEntry) to GET endpoints - Apply delta adjustment to netPerformanceInPercentage (was cumulative) - Fix realized profit to only include dividend/interest income (exclude SELL gross proceeds) - Fix formatCurrency hiding $0 values (only hide null/undefined) - Localize monthLabel, weekday headers, and dateLabel using Intl.DateTimeFormat - Add snackbar feedback on note save/delete success and failure - Remove unused date field from DTO, add @IsNotEmpty to note - Add DEFAULT_CURRENCY fallback for userCurrency - Add month/year range validation - Fix January baseline by fetching previous year performance - Add maxWidth to dialog for mobile - Only reload calendar data when dialog reports changes - Remove unused GfActivitiesTableComponent and user subscription from dialog - Handle empty note save gracefully (skip API call if no existing note) - Add dark mode support to dialog SCSS - Add route title for browser tab https://claude.ai/code/session_01XEieh1hHrXc1fcbnA7oBHn --- .../api/src/app/journal/journal.controller.ts | 57 ++++++++++++---- .../journal-day-dialog.component.html | 8 +-- .../journal-day-dialog.component.scss | 32 +++++++++ .../journal-day-dialog.component.ts | 65 +++++++++++++------ .../journal/journal-page.component.ts | 7 +- .../portfolio/journal/journal-page.routes.ts | 5 +- .../create-or-update-journal-entry.dto.ts | 6 +- libs/common/src/lib/permissions.ts | 3 + .../journal-calendar.component.html | 8 ++- .../journal-calendar.component.ts | 47 ++++++++++---- libs/ui/src/lib/services/data.service.ts | 2 +- 11 files changed, 183 insertions(+), 57 deletions(-) diff --git a/apps/api/src/app/journal/journal.controller.ts b/apps/api/src/app/journal/journal.controller.ts index 36dd5ea12..91e9d5134 100644 --- a/apps/api/src/app/journal/journal.controller.ts +++ b/apps/api/src/app/journal/journal.controller.ts @@ -2,6 +2,7 @@ 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 { @@ -25,7 +26,13 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { endOfMonth, format, parseISO, startOfMonth } from 'date-fns'; +import { + endOfMonth, + format, + parseISO, + startOfMonth, + subMonths +} from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { JournalService } from './journal.service'; @@ -40,6 +47,7 @@ export class JournalController { ) {} @Get() + @HasPermission(permissions.readJournalEntry) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getJournal( @Query('month') month: string, @@ -49,16 +57,31 @@ export class JournalController { const monthNum = parseInt(month, 10) - 1; const yearNum = parseInt(year, 10); - if (isNaN(monthNum) || isNaN(yearNum)) { + 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({ @@ -67,17 +90,18 @@ export class JournalController { userId }), this.portfolioService.getPerformance({ - dateRange: `${yearNum}`, + dateRange: + `${performanceYear}` === `${yearNum}` ? `${yearNum}` : 'max', filters: [], impersonationId: undefined, userId }), this.orderService.getOrders({ - startDate, endDate, userId, - userCurrency: this.request.user.settings?.settings?.baseCurrency, + userCurrency, includeDrafts: false, + startDate: startDate, withExcludedAccountsAndActivities: false }) ]); @@ -104,26 +128,35 @@ export class JournalController { } } - // Calculate daily performance deltas + // 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 calculate realized profit + // 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); @@ -143,11 +176,10 @@ export class JournalController { day.activitiesCount++; - if ( - activity.type === 'SELL' || - activity.type === 'DIVIDEND' || - activity.type === 'INTEREST' - ) { + // 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; } @@ -173,6 +205,7 @@ export class JournalController { } @Get(':date') + @HasPermission(permissions.readJournalEntry) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getJournalEntry(@Param('date') dateString: string) { const userId = this.request.user.id; 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 index 69ed8f622..118aac886 100644 --- 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 @@ -1,7 +1,7 @@
@@ -20,7 +20,7 @@ @if (data.realizedProfit !== 0) {
-
Realized P&L
+
Dividend & Interest
{{ activity.type }} @@ -119,5 +119,5 @@ 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 index 8068cfa81..366691a88 100644 --- 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 @@ -63,3 +63,35 @@ 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 index d83ad83dd..590b024f0 100644 --- 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 @@ -1,7 +1,5 @@ -import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; -import { Activity, User } from '@ghostfolio/common/interfaces'; -import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +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'; @@ -25,6 +23,7 @@ import { } 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'; @@ -45,14 +44,14 @@ export interface JournalDayDialogData { imports: [ CommonModule, FormsModule, - GfActivitiesTableComponent, GfDialogFooterComponent, GfDialogHeaderComponent, GfValueComponent, MatButtonModule, MatDialogModule, MatFormFieldModule, - MatInputModule + MatInputModule, + MatSnackBarModule ], selector: 'gf-journal-day-dialog', styleUrls: ['./journal-day-dialog.component.scss'], @@ -62,12 +61,13 @@ 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 = ''; - public user: User; + private originalNote = ''; private unsubscribeSubject = new Subject(); public constructor( @@ -75,21 +75,17 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy { @Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData, private dataService: DataService, public dialogRef: MatDialogRef, - private userService: UserService + private snackBar: MatSnackBar ) {} public ngOnInit() { this.date = this.data.date; - this.dateLabel = format(parseISO(this.date), 'EEEE, MMMM d, yyyy'); - - this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((state) => { - if (state?.user) { - this.user = state.user; - this.changeDetectorRef.markForCheck(); - } - }); + 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(); @@ -108,31 +104,55 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy { .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 { + } 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(); @@ -141,11 +161,16 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy { 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: 50, + take: 500, sortColumn: 'date', sortDirection: 'desc' }) @@ -174,11 +199,13 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy { .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 index d1eb2263e..e62b03856 100644 --- a/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.component.ts @@ -89,14 +89,17 @@ export class JournalPageComponent implements OnInit, OnDestroy { 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(() => { - this.loadJournalData(); + .subscribe((result) => { + if (result?.hasChanges) { + this.loadJournalData(); + } }); } 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 index 7a97f7256..744c45f42 100644 --- a/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts +++ b/apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts @@ -1,3 +1,5 @@ +import { internalRoutes } from '@ghostfolio/common/routes/routes'; + import { Routes } from '@angular/router'; import { JournalPageComponent } from './journal-page.component'; @@ -5,6 +7,7 @@ import { JournalPageComponent } from './journal-page.component'; export const routes: Routes = [ { component: JournalPageComponent, - path: '' + path: '', + title: internalRoutes.portfolio.subRoutes.journal.title } ]; 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 index 627291664..d12c3265b 100644 --- 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 @@ -1,9 +1,7 @@ -import { IsISO8601, IsString, MaxLength } from 'class-validator'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; export class CreateOrUpdateJournalEntryDto { - @IsISO8601() - date: string; - + @IsNotEmpty() @IsString() @MaxLength(10000) note: string; diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 21f5e64f3..41fb1220a 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -43,6 +43,7 @@ export const permissions = { enableSystemMessage: 'enableSystemMessage', impersonateAllUsers: 'impersonateAllUsers', readAiPrompt: 'readAiPrompt', + readJournalEntry: 'readJournalEntry', readMarketData: 'readMarketData', readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', @@ -96,6 +97,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteTag, permissions.deleteUser, permissions.readAiPrompt, + permissions.readJournalEntry, permissions.readMarketData, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, @@ -144,6 +146,7 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteOrder, permissions.deleteWatchlistItem, permissions.readAiPrompt, + permissions.readJournalEntry, permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readWatchlist, diff --git a/libs/ui/src/lib/journal-calendar/journal-calendar.component.html b/libs/ui/src/lib/journal-calendar/journal-calendar.component.html index e96c00765..7b7e52e08 100644 --- a/libs/ui/src/lib/journal-calendar/journal-calendar.component.html +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.html @@ -101,7 +101,7 @@ class="calendar-cell day-cell" [class.clickable]="day.isCurrentMonth" [class.today]="day.isToday" - [class]="getDayClass(day)" + [ngClass]="getDayClass(day)" (click)="onDayClick(day)" >
{{ day.dayOfMonth }}
@@ -111,8 +111,10 @@
@if (day.data.activitiesCount > 0) {
- {{ day.data.activitiesCount }} - {{ day.data.activitiesCount === 1 ? 'trade' : 'trades' }} + {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.ts b/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts index b30785e3e..24c62b8ae 100644 --- a/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts +++ b/libs/ui/src/lib/journal-calendar/journal-calendar.component.ts @@ -10,7 +10,8 @@ import { EventEmitter, Input, OnChanges, - Output + Output, + SimpleChanges } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; @@ -21,6 +22,7 @@ import { endOfMonth, format, getDay, + isToday, startOfMonth, subMonths } from 'date-fns'; @@ -58,10 +60,13 @@ export class GfJournalCalendarComponent implements OnChanges { @Output() monthChanged = new EventEmitter<{ month: number; year: number }>(); public calendarWeeks: CalendarDay[][] = []; - public weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + public weekDays: string[] = []; public get monthLabel(): string { - return format(new Date(this.year, this.month - 1), 'MMMM yyyy'); + return new Intl.DateTimeFormat(this.locale ?? 'en-US', { + month: 'long', + year: 'numeric' + }).format(new Date(this.year, this.month - 1)); } public get winRate(): number { @@ -82,8 +87,14 @@ export class GfJournalCalendarComponent implements OnChanges { }); } - public ngOnChanges() { - this.buildCalendar(); + public ngOnChanges(changes: SimpleChanges) { + if (changes['days'] || changes['month'] || changes['year']) { + this.buildCalendar(); + } + + if (changes['locale']) { + this.buildWeekDays(); + } } public onPreviousMonth() { @@ -129,7 +140,7 @@ export class GfJournalCalendarComponent implements OnChanges { } public formatCurrency(value: number): string { - if (value === 0 || value === undefined || value === null) { + if (value === undefined || value === null) { return ''; } @@ -145,11 +156,28 @@ export class GfJournalCalendarComponent implements OnChanges { } } + 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 today = new Date(); const daysDataMap = new Map(); @@ -181,10 +209,7 @@ export class GfJournalCalendarComponent implements OnChanges { date, dayOfMonth: date.getDate(), isCurrentMonth: true, - isToday: - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() + isToday: isToday(date) }); } diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index acee1281a..23d5cb686 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -432,7 +432,7 @@ export class DataService { } public putJournalEntry({ date, note }: { date: string; note: string }) { - return this.http.put(`/api/v1/journal/${date}`, { date, note }); + return this.http.put(`/api/v1/journal/${date}`, { note }); } public deleteJournalEntry({ date }: { date: string }) {