Browse Source

Merge 3f96f67f9f into d4a0f48ca2

pull/6340/merge
Amir Moradi 2 days ago
committed by GitHub
parent
commit
5d21e15f54
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      apps/api/src/app/app.module.ts
  2. 249
      apps/api/src/app/journal/journal.controller.ts
  3. 16
      apps/api/src/app/journal/journal.module.ts
  4. 161
      apps/api/src/app/journal/journal.service.ts
  5. 123
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html
  6. 97
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss
  7. 214
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts
  8. 133
      apps/client/src/app/pages/portfolio/journal/journal-page.component.ts
  9. 21
      apps/client/src/app/pages/portfolio/journal/journal-page.html
  10. 13
      apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts
  11. 3
      apps/client/src/app/pages/portfolio/journal/journal-page.scss
  12. 7
      apps/client/src/app/pages/portfolio/portfolio-page.component.ts
  13. 5
      apps/client/src/app/pages/portfolio/portfolio-page.routes.ts
  14. 8
      libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts
  15. 2
      libs/common/src/lib/dtos/index.ts
  16. 10
      libs/common/src/lib/interfaces/index.ts
  17. 30
      libs/common/src/lib/interfaces/responses/journal-response.interface.ts
  18. 12
      libs/common/src/lib/permissions.ts
  19. 5
      libs/common/src/lib/routes/routes.ts
  20. 1
      libs/ui/src/lib/journal-calendar/index.ts
  21. 129
      libs/ui/src/lib/journal-calendar/journal-calendar.component.html
  22. 185
      libs/ui/src/lib/journal-calendar/journal-calendar.component.scss
  23. 238
      libs/ui/src/lib/journal-calendar/journal-calendar.component.ts
  24. 22
      libs/ui/src/lib/services/data.service.ts
  25. 23
      prisma/migrations/20260214000000_added_journal_entry/migration.sql
  26. 37
      prisma/schema.prisma

2
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,

249
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<JournalResponse> {
const userId = this.request.user.id;
const monthNum = parseInt(month, 10) - 1;
const yearNum = parseInt(year, 10);
if (
isNaN(monthNum) ||
isNaN(yearNum) ||
monthNum < 0 ||
monthNum > 11 ||
yearNum < 1970 ||
yearNum > 2100
) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const userCurrency =
this.request.user.settings?.settings?.baseCurrency ?? DEFAULT_CURRENCY;
const startDate = startOfMonth(new Date(yearNum, monthNum));
const endDate = endOfMonth(new Date(yearNum, monthNum));
// Fetch performance with a baseline that includes the previous month's
// last day, so January deltas are computed correctly
const performanceFetchStart = subMonths(startDate, 1);
const performanceYear = performanceFetchStart.getFullYear();
const [journalEntries, performanceResponse, activitiesResponse] =
await Promise.all([
this.journalService.getJournalEntries({
startDate,
endDate,
userId
}),
this.portfolioService.getPerformance({
dateRange:
`${performanceYear}` === `${yearNum}` ? `${yearNum}` : 'max',
filters: [],
impersonationId: undefined,
userId
}),
this.orderService.getOrders({
endDate,
userId,
userCurrency,
includeDrafts: false,
startDate: startDate,
withExcludedAccountsAndActivities: false
})
]);
const chart = performanceResponse?.chart ?? [];
const activities = activitiesResponse?.activities ?? [];
const daysMap = new Map<string, JournalCalendarDataItem>();
// Build days from performance chart data
for (const item of chart) {
const itemDate = parseISO(item.date);
if (itemDate >= startDate && itemDate <= endDate) {
daysMap.set(item.date, {
activitiesCount: 0,
date: item.date,
hasNote: false,
netPerformance: item.netPerformance ?? 0,
netPerformanceInPercentage: item.netPerformanceInPercentage ?? 0,
realizedProfit: 0,
value: item.value ?? 0
});
}
}
// Calculate daily performance deltas (both absolute and percentage)
const sortedDates = Array.from(daysMap.keys()).sort();
let previousNetPerformance = 0;
let previousNetPerformanceInPercentage = 0;
// Find the chart item just before our month starts to get the baseline
for (const item of chart) {
const itemDate = parseISO(item.date);
if (itemDate < startDate) {
previousNetPerformance = item.netPerformance ?? 0;
previousNetPerformanceInPercentage =
item.netPerformanceInPercentage ?? 0;
}
}
for (const dateKey of sortedDates) {
const day = daysMap.get(dateKey);
const currentNetPerformance = day.netPerformance;
day.netPerformance = currentNetPerformance - previousNetPerformance;
previousNetPerformance = currentNetPerformance;
const currentNetPerformanceInPercentage = day.netPerformanceInPercentage;
day.netPerformanceInPercentage =
currentNetPerformanceInPercentage - previousNetPerformanceInPercentage;
previousNetPerformanceInPercentage = currentNetPerformanceInPercentage;
}
// Count activities per day and track dividend/interest income
for (const activity of activities) {
const dateKey = format(activity.date, DATE_FORMAT);
let day = daysMap.get(dateKey);
if (!day) {
day = {
activitiesCount: 0,
date: dateKey,
hasNote: false,
netPerformance: 0,
netPerformanceInPercentage: 0,
realizedProfit: 0,
value: 0
};
daysMap.set(dateKey, day);
}
day.activitiesCount++;
// Track dividend and interest income as realized profit.
// SELL activities use gross proceeds (not actual realized gain),
// so we exclude them to avoid misleading values.
if (activity.type === 'DIVIDEND' || activity.type === 'INTEREST') {
day.realizedProfit +=
activity.valueInBaseCurrency ?? activity.value ?? 0;
}
}
// Mark days with journal notes
for (const entry of journalEntries) {
const dateKey = format(entry.date, DATE_FORMAT);
const day = daysMap.get(dateKey);
if (day) {
day.hasNote = true;
}
}
const days = Array.from(daysMap.values()).sort((a, b) =>
a.date.localeCompare(b.date)
);
const stats = this.journalService.buildJournalStats(days);
return { days, stats };
}
@Get(':date')
@HasPermission(permissions.readJournalEntry)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJournalEntry(@Param('date') dateString: string) {
const userId = this.request.user.id;
const date = resetHours(parseISO(dateString));
return this.journalService.getJournalEntry({ date, userId });
}
@HasPermission(permissions.createJournalEntry)
@Put(':date')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createOrUpdateJournalEntry(
@Param('date') dateString: string,
@Body() data: CreateOrUpdateJournalEntryDto
) {
const userId = this.request.user.id;
return this.journalService.createOrUpdateJournalEntry({
date: dateString,
note: data.note,
userId
});
}
@HasPermission(permissions.deleteJournalEntry)
@Delete(':date')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJournalEntry(@Param('date') dateString: string) {
const userId = this.request.user.id;
const date = resetHours(parseISO(dateString));
try {
return await this.journalService.deleteJournalEntry({ date, userId });
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
}
}

16
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 {}

161
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<JournalEntryItem | null> {
const entry = await this.prismaService.journalEntry.findUnique({
where: {
userId_date: {
userId,
date: resetHours(date)
}
}
});
if (!entry) {
return null;
}
return {
date: format(entry.date, DATE_FORMAT),
note: entry.note
};
}
public async getJournalEntries({
startDate,
endDate,
userId
}: {
startDate: Date;
endDate: Date;
userId: string;
}): Promise<JournalEntry[]> {
return this.prismaService.journalEntry.findMany({
where: {
userId,
date: {
gte: resetHours(startDate),
lte: resetHours(endDate)
}
},
orderBy: {
date: 'asc'
}
});
}
public async createOrUpdateJournalEntry({
date,
note,
userId
}: {
date: string;
note: string;
userId: string;
}): Promise<JournalEntry> {
const parsedDate = resetHours(parseISO(date));
return this.prismaService.journalEntry.upsert({
create: {
date: parsedDate,
note,
user: {
connect: { id: userId }
}
},
update: {
note
},
where: {
userId_date: {
userId,
date: parsedDate
}
}
});
}
public async deleteJournalEntry({
date,
userId
}: {
date: Date;
userId: string;
}): Promise<JournalEntry> {
return this.prismaService.journalEntry.delete({
where: {
userId_date: {
userId,
date: resetHours(date)
}
}
});
}
public buildJournalStats(days: JournalCalendarDataItem[]): JournalStats {
const tradingDays = days.filter(
(d) => d.activitiesCount > 0 || d.netPerformance !== 0
);
const winningDays = tradingDays.filter((d) => d.netPerformance > 0);
const losingDays = tradingDays.filter((d) => d.netPerformance < 0);
const profits = tradingDays.map((d) => d.netPerformance);
const largestProfit = profits.length > 0 ? Math.max(...profits) : 0;
const largestLoss = profits.length > 0 ? Math.min(...profits) : 0;
const averageDailyPnL =
tradingDays.length > 0
? profits.reduce((sum, val) => sum + val, 0) / tradingDays.length
: 0;
let maxConsecutiveWins = 0;
let maxConsecutiveLosses = 0;
let currentWins = 0;
let currentLosses = 0;
for (const day of tradingDays) {
if (day.netPerformance > 0) {
currentWins++;
currentLosses = 0;
maxConsecutiveWins = Math.max(maxConsecutiveWins, currentWins);
} else if (day.netPerformance < 0) {
currentLosses++;
currentWins = 0;
maxConsecutiveLosses = Math.max(maxConsecutiveLosses, currentLosses);
} else {
currentWins = 0;
currentLosses = 0;
}
}
return {
averageDailyPnL,
largestLoss,
largestProfit,
losingDays: losingDays.length,
maxConsecutiveLosses,
maxConsecutiveWins,
totalTradingDays: tradingDays.length,
winningDays: winningDays.length
};
}
}

123
apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html

@ -0,0 +1,123 @@
<gf-dialog-header
[deviceType]="data.deviceType"
[title]="dateLabel"
(closeButtonClicked)="onClose()"
/>
<div class="flex-grow-1 overflow-auto px-3" mat-dialog-content>
<!-- Performance summary -->
<div class="d-flex flex-wrap gap-4 mb-4">
<div class="performance-metric">
<div class="metric-label" i18n>Portfolio Change</div>
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="data.netPerformance"
/>
</div>
@if (data.realizedProfit !== 0) {
<div class="performance-metric">
<div class="metric-label" i18n>Dividend &amp; Interest</div>
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="data.realizedProfit"
/>
</div>
}
<div class="performance-metric">
<div class="metric-label" i18n>Activities</div>
<gf-value [value]="data.activitiesCount" />
</div>
</div>
<!-- Activities list -->
@if (activities.length > 0) {
<h4 class="mb-2" i18n>Activities</h4>
<div class="activities-list mb-4">
<table class="w-100">
<thead>
<tr>
<th i18n>Type</th>
<th i18n>Symbol</th>
<th class="text-end" i18n>Quantity</th>
<th class="text-end" i18n>Unit Price</th>
<th class="text-end" i18n>Value</th>
</tr>
</thead>
<tbody>
@for (activity of activities; track activity.id) {
<tr>
<td>
<span
class="activity-type"
[ngClass]="activity.type?.toLowerCase()"
>
{{ activity.type }}
</span>
</td>
<td>{{ activity.SymbolProfile?.symbol ?? '-' }}</td>
<td class="text-end">{{ activity.quantity | number }}</td>
<td class="text-end">
<gf-value
size="small"
[isCurrency]="true"
[locale]="data.locale"
[unit]="activity.SymbolProfile?.currency"
[value]="activity.unitPrice"
/>
</td>
<td class="text-end">
<gf-value
size="small"
[isCurrency]="true"
[locale]="data.locale"
[unit]="activity.SymbolProfile?.currency"
[value]="activity.value"
/>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Journal Note -->
<h4 class="mb-2" i18n>Journal Note</h4>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n
>Write your thoughts, observations, or strategy notes for this
day...</mat-label
>
<textarea
matInput
rows="6"
[disabled]="isLoadingNote"
[(ngModel)]="note"
></textarea>
</mat-form-field>
<div class="d-flex justify-content-end mb-3">
<button
color="primary"
i18n
mat-flat-button
[disabled]="isSavingNote || isLoadingNote"
(click)="onSaveNote()"
>
Save Note
</button>
</div>
</div>
<gf-dialog-footer
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
/>

97
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);
}
}
}

214
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<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData,
private dataService: DataService,
public dialogRef: MatDialogRef<GfJournalDayDialogComponent>,
private snackBar: MatSnackBar
) {}
public ngOnInit() {
this.date = this.data.date;
this.dateLabel = new Intl.DateTimeFormat(this.data.locale ?? 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(parseISO(this.date));
this.loadActivities();
this.loadNote();
}
public onSaveNote() {
if (this.isSavingNote) {
return;
}
this.isSavingNote = true;
if (this.note?.trim()) {
this.dataService
.putJournalEntry({ date: this.date, note: this.note.trim() })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.hasChanges = true;
this.originalNote = this.note.trim();
this.isSavingNote = false;
this.snackBar.open($localize`Note saved`, undefined, {
duration: 3000
});
this.changeDetectorRef.markForCheck();
},
error: () => {
this.isSavingNote = false;
this.snackBar.open($localize`Failed to save note`, undefined, {
duration: 5000
});
this.changeDetectorRef.markForCheck();
}
});
} else if (this.originalNote) {
// Only call delete if there was an existing note
this.dataService
.deleteJournalEntry({ date: this.date })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.hasChanges = true;
this.originalNote = '';
this.isSavingNote = false;
this.snackBar.open($localize`Note deleted`, undefined, {
duration: 3000
});
this.changeDetectorRef.markForCheck();
},
error: () => {
this.isSavingNote = false;
this.snackBar.open($localize`Failed to delete note`, undefined, {
duration: 5000
});
this.changeDetectorRef.markForCheck();
}
});
} else {
// No existing note and empty input - nothing to do
this.isSavingNote = false;
}
}
public onClose() {
this.dialogRef.close({ hasChanges: this.hasChanges });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadActivities() {
this.isLoadingActivities = true;
// Use the year of the selected date as range filter to avoid
// fetching all-time activities and missing older dates
const year = this.date.substring(0, 4);
this.dataService
.fetchActivities({
filters: [],
range: year as any,
skip: 0,
take: 500,
sortColumn: 'date',
sortDirection: 'desc'
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities.filter((activity) => {
const activityDate = format(
typeof activity.date === 'string'
? parseISO(activity.date as string)
: activity.date,
DATE_FORMAT
);
return activityDate === this.date;
});
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
}
private loadNote() {
this.isLoadingNote = true;
this.dataService
.fetchJournalEntry({ date: this.date })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: (entry) => {
this.note = entry?.note ?? '';
this.originalNote = this.note;
this.isLoadingNote = false;
this.changeDetectorRef.markForCheck();
},
error: () => {
this.note = '';
this.originalNote = '';
this.isLoadingNote = false;
this.changeDetectorRef.markForCheck();
}
});
}
}

133
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<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private userService: UserService
) {
const now = new Date();
this.month = now.getMonth() + 1;
this.year = now.getFullYear();
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.loadJournalData();
}
});
}
public onMonthChanged({ month, year }: { month: number; year: number }) {
this.month = month;
this.year = year;
this.loadJournalData();
}
public onDayClicked(date: string) {
const dayData = this.days.find((d) => d.date === date);
const dialogRef = this.dialog.open(GfJournalDayDialogComponent, {
autoFocus: false,
data: {
activitiesCount: dayData?.activitiesCount ?? 0,
baseCurrency: this.user?.settings?.baseCurrency ?? 'USD',
date,
deviceType: this.deviceType,
locale: this.user?.settings?.locale,
netPerformance: dayData?.netPerformance ?? 0,
realizedProfit: dayData?.realizedProfit ?? 0
} as JournalDayDialogData,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '100vw' : '50rem',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((result) => {
if (result?.hasChanges) {
this.loadJournalData();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private loadJournalData() {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
this.dataService
.fetchJournal({ month: this.month, year: this.year })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: (response) => {
this.days = response.days;
this.stats = response.stats;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
},
error: () => {
this.days = [];
this.stats = undefined;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}
});
}
}

21
apps/client/src/app/pages/portfolio/journal/journal-page.html

@ -0,0 +1,21 @@
@if (isLoading) {
<div class="p-3">
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '400px', width: '100%' }"
/>
</div>
} @else {
<div class="container p-3">
<gf-journal-calendar
[baseCurrency]="user?.settings?.baseCurrency"
[days]="days"
[locale]="user?.settings?.locale"
[month]="month"
[stats]="stats"
[year]="year"
(dayClicked)="onDayClicked($event)"
(monthChanged)="onMonthChanged($event)"
/>
</div>
}

13
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
}
];

3
apps/client/src/app/pages/portfolio/journal/journal-page.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

7
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,

5
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: () =>

8
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;
}

2
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,

10
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,

30
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;
}

12
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,

5
libs/common/src/lib/routes/routes.ts

@ -142,6 +142,11 @@ export const internalRoutes: Record<string, InternalRoute> = {
routerLink: ['/portfolio', 'fire'],
title: 'FIRE'
},
journal: {
path: 'journal',
routerLink: ['/portfolio', 'journal'],
title: $localize`Journal`
},
xRay: {
path: 'x-ray',
routerLink: ['/portfolio', 'x-ray'],

1
libs/ui/src/lib/journal-calendar/index.ts

@ -0,0 +1 @@
export * from './journal-calendar.component';

129
libs/ui/src/lib/journal-calendar/journal-calendar.component.html

@ -0,0 +1,129 @@
<div class="journal-calendar">
<!-- Header with navigation -->
<div
class="calendar-header d-flex align-items-center justify-content-between mb-3"
>
<button mat-icon-button (click)="onPreviousMonth()">
<ion-icon name="chevron-back-outline" />
</button>
<h3 class="mb-0">{{ monthLabel }}</h3>
<button mat-icon-button (click)="onNextMonth()">
<ion-icon name="chevron-forward-outline" />
</button>
</div>
<!-- Stats bar -->
@if (stats) {
<div class="stats-bar d-flex flex-wrap gap-3 mb-3">
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value">{{ stats.totalTradingDays }}</div>
<div class="stat-label" i18n>Trading Days</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-success">{{ stats.winningDays }}</div>
<div class="stat-label" i18n>Winning Days</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-danger">{{ stats.losingDays }}</div>
<div class="stat-label" i18n>Losing Days</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value">{{ winRate }}%</div>
<div class="stat-label" i18n>Win Rate</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value">
{{ formatCurrency(stats.averageDailyPnL) }}
</div>
<div class="stat-label" i18n>Avg Daily P&amp;L</div>
</mat-card-content>
</mat-card>
</div>
<!-- Additional stats -->
<div class="stats-bar d-flex flex-wrap gap-3 mb-3">
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-success">
{{ formatCurrency(stats.largestProfit) }}
</div>
<div class="stat-label" i18n>Largest Profit</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-danger">
{{ formatCurrency(stats.largestLoss) }}
</div>
<div class="stat-label" i18n>Largest Loss</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-success">
{{ stats.maxConsecutiveWins }}
</div>
<div class="stat-label" i18n>Max Consecutive Wins</div>
</mat-card-content>
</mat-card>
<mat-card class="stat-card flex-fill">
<mat-card-content>
<div class="stat-value text-danger">
{{ stats.maxConsecutiveLosses }}
</div>
<div class="stat-label" i18n>Max Consecutive Losses</div>
</mat-card-content>
</mat-card>
</div>
}
<!-- Calendar grid -->
<div class="calendar-grid">
<div class="calendar-week-header d-flex">
@for (weekDay of weekDays; track weekDay) {
<div class="calendar-cell header-cell">{{ weekDay }}</div>
}
</div>
@for (week of calendarWeeks; track $index) {
<div class="calendar-week d-flex">
@for (day of week; track day.date) {
<div
class="calendar-cell day-cell"
[class.clickable]="day.isCurrentMonth"
[class.today]="day.isToday"
[ngClass]="getDayClass(day)"
(click)="onDayClick(day)"
>
<div class="day-number">{{ day.dayOfMonth }}</div>
@if (day.data && day.isCurrentMonth) {
<div class="day-pnl">
{{ formatCurrency(day.data.netPerformance) }}
</div>
@if (day.data.activitiesCount > 0) {
<div class="day-activities">
<span i18n>{day.data.activitiesCount, plural,
=1 {1 activity}
other {{{day.data.activitiesCount}} activities}
}</span>
</div>
}
@if (day.data.hasNote) {
<ion-icon class="note-icon" name="document-text-outline" />
}
}
</div>
}
</div>
}
</div>
</div>

185
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);
}
}
}

238
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<string>();
@Output() monthChanged = new EventEmitter<{ month: number; year: number }>();
public calendarWeeks: CalendarDay[][] = [];
public weekDays: string[] = [];
public get monthLabel(): string {
return new Intl.DateTimeFormat(this.locale ?? 'en-US', {
month: 'long',
year: 'numeric'
}).format(new Date(this.year, this.month - 1));
}
public get winRate(): number {
if (!this.stats || this.stats.totalTradingDays === 0) {
return 0;
}
return Math.round(
(this.stats.winningDays / this.stats.totalTradingDays) * 100
);
}
public constructor() {
addIcons({
chevronBackOutline,
chevronForwardOutline,
documentTextOutline
});
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['days'] || changes['month'] || changes['year']) {
this.buildCalendar();
}
if (changes['locale']) {
this.buildWeekDays();
}
}
public onPreviousMonth() {
const prev = subMonths(new Date(this.year, this.month - 1), 1);
this.monthChanged.emit({
month: prev.getMonth() + 1,
year: prev.getFullYear()
});
}
public onNextMonth() {
const next = addMonths(new Date(this.year, this.month - 1), 1);
this.monthChanged.emit({
month: next.getMonth() + 1,
year: next.getFullYear()
});
}
public onDayClick(day: CalendarDay) {
if (day.isCurrentMonth) {
this.dayClicked.emit(format(day.date, 'yyyy-MM-dd'));
}
}
public getDayClass(day: CalendarDay): string {
if (!day.isCurrentMonth) {
return 'other-month';
}
if (!day.data) {
return '';
}
if (day.data.netPerformance > 0) {
return 'positive';
}
if (day.data.netPerformance < 0) {
return 'negative';
}
return 'neutral';
}
public formatCurrency(value: number): string {
if (value === undefined || value === null) {
return '';
}
try {
return new Intl.NumberFormat(this.locale ?? 'en-US', {
style: 'currency',
currency: this.baseCurrency ?? 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
} catch {
return value.toFixed(0);
}
}
private buildWeekDays() {
// Generate locale-aware weekday names (Sunday-start)
const baseDate = new Date(2024, 0, 7); // A known Sunday
this.weekDays = Array.from({ length: 7 }, (_, i) => {
const day = new Date(baseDate);
day.setDate(baseDate.getDate() + i);
return new Intl.DateTimeFormat(this.locale ?? 'en-US', {
weekday: 'short'
}).format(day);
});
}
private buildCalendar() {
if (!this.weekDays.length) {
this.buildWeekDays();
}
const currentDate = new Date(this.year, this.month - 1);
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const daysDataMap = new Map<string, JournalCalendarDataItem>();
for (const day of this.days) {
daysDataMap.set(day.date, day);
}
const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDayOfWeek = getDay(monthStart);
const calendarDays: CalendarDay[] = [];
// Padding for days before the month starts
for (let i = 0; i < startDayOfWeek; i++) {
const date = new Date(this.year, this.month - 1, -startDayOfWeek + i + 1);
calendarDays.push({
date,
dayOfMonth: date.getDate(),
isCurrentMonth: false,
isToday: false
});
}
// Actual days
for (const date of daysInMonth) {
const dateKey = format(date, 'yyyy-MM-dd');
calendarDays.push({
data: daysDataMap.get(dateKey),
date,
dayOfMonth: date.getDate(),
isCurrentMonth: true,
isToday: isToday(date)
});
}
// Pad to complete the last week
while (calendarDays.length % 7 !== 0) {
const lastDate = calendarDays[calendarDays.length - 1].date;
const date = new Date(
lastDate.getFullYear(),
lastDate.getMonth(),
lastDate.getDate() + 1
);
calendarDays.push({
date,
dayOfMonth: date.getDate(),
isCurrentMonth: false,
isToday: false
});
}
// Group into weeks
this.calendarWeeks = [];
for (let i = 0; i < calendarDays.length; i += 7) {
this.calendarWeeks.push(calendarDays.slice(i, i + 7));
}
}
}

22
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<JournalResponse>('/api/v1/journal', { params });
}
public fetchJournalEntry({ date }: { date: string }) {
return this.http.get<JournalEntryItem>(`/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

23
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;

37
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?

Loading…
Cancel
Save