Browse Source

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
pull/6340/head
Claude 2 weeks ago
parent
commit
6f01b52194
Failed to extract signature
  1. 57
      apps/api/src/app/journal/journal.controller.ts
  2. 8
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.html
  3. 32
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss
  4. 65
      apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.ts
  5. 5
      apps/client/src/app/pages/portfolio/journal/journal-page.component.ts
  6. 5
      apps/client/src/app/pages/portfolio/journal/journal-page.routes.ts
  7. 6
      libs/common/src/lib/dtos/create-or-update-journal-entry.dto.ts
  8. 3
      libs/common/src/lib/permissions.ts
  9. 8
      libs/ui/src/lib/journal-calendar/journal-calendar.component.html
  10. 45
      libs/ui/src/lib/journal-calendar/journal-calendar.component.ts
  11. 2
      libs/ui/src/lib/services/data.service.ts

57
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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { CreateOrUpdateJournalEntryDto } from '@ghostfolio/common/dtos'; import { CreateOrUpdateJournalEntryDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { import {
@ -25,7 +26,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; 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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { JournalService } from './journal.service'; import { JournalService } from './journal.service';
@ -40,6 +47,7 @@ export class JournalController {
) {} ) {}
@Get() @Get()
@HasPermission(permissions.readJournalEntry)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJournal( public async getJournal(
@Query('month') month: string, @Query('month') month: string,
@ -49,16 +57,31 @@ export class JournalController {
const monthNum = parseInt(month, 10) - 1; const monthNum = parseInt(month, 10) - 1;
const yearNum = parseInt(year, 10); 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( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
const userCurrency =
this.request.user.settings?.settings?.baseCurrency ?? DEFAULT_CURRENCY;
const startDate = startOfMonth(new Date(yearNum, monthNum)); const startDate = startOfMonth(new Date(yearNum, monthNum));
const endDate = endOfMonth(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] = const [journalEntries, performanceResponse, activitiesResponse] =
await Promise.all([ await Promise.all([
this.journalService.getJournalEntries({ this.journalService.getJournalEntries({
@ -67,17 +90,18 @@ export class JournalController {
userId userId
}), }),
this.portfolioService.getPerformance({ this.portfolioService.getPerformance({
dateRange: `${yearNum}`, dateRange:
`${performanceYear}` === `${yearNum}` ? `${yearNum}` : 'max',
filters: [], filters: [],
impersonationId: undefined, impersonationId: undefined,
userId userId
}), }),
this.orderService.getOrders({ this.orderService.getOrders({
startDate,
endDate, endDate,
userId, userId,
userCurrency: this.request.user.settings?.settings?.baseCurrency, userCurrency,
includeDrafts: false, includeDrafts: false,
startDate: startDate,
withExcludedAccountsAndActivities: false 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(); const sortedDates = Array.from(daysMap.keys()).sort();
let previousNetPerformance = 0; let previousNetPerformance = 0;
let previousNetPerformanceInPercentage = 0;
// Find the chart item just before our month starts to get the baseline // Find the chart item just before our month starts to get the baseline
for (const item of chart) { for (const item of chart) {
const itemDate = parseISO(item.date); const itemDate = parseISO(item.date);
if (itemDate < startDate) { if (itemDate < startDate) {
previousNetPerformance = item.netPerformance ?? 0; previousNetPerformance = item.netPerformance ?? 0;
previousNetPerformanceInPercentage =
item.netPerformanceInPercentage ?? 0;
} }
} }
for (const dateKey of sortedDates) { for (const dateKey of sortedDates) {
const day = daysMap.get(dateKey); const day = daysMap.get(dateKey);
const currentNetPerformance = day.netPerformance; const currentNetPerformance = day.netPerformance;
day.netPerformance = currentNetPerformance - previousNetPerformance; day.netPerformance = currentNetPerformance - previousNetPerformance;
previousNetPerformance = currentNetPerformance; 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) { for (const activity of activities) {
const dateKey = format(activity.date, DATE_FORMAT); const dateKey = format(activity.date, DATE_FORMAT);
let day = daysMap.get(dateKey); let day = daysMap.get(dateKey);
@ -143,11 +176,10 @@ export class JournalController {
day.activitiesCount++; day.activitiesCount++;
if ( // Track dividend and interest income as realized profit.
activity.type === 'SELL' || // SELL activities use gross proceeds (not actual realized gain),
activity.type === 'DIVIDEND' || // so we exclude them to avoid misleading values.
activity.type === 'INTEREST' if (activity.type === 'DIVIDEND' || activity.type === 'INTEREST') {
) {
day.realizedProfit += day.realizedProfit +=
activity.valueInBaseCurrency ?? activity.value ?? 0; activity.valueInBaseCurrency ?? activity.value ?? 0;
} }
@ -173,6 +205,7 @@ export class JournalController {
} }
@Get(':date') @Get(':date')
@HasPermission(permissions.readJournalEntry)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJournalEntry(@Param('date') dateString: string) { public async getJournalEntry(@Param('date') dateString: string) {
const userId = this.request.user.id; const userId = this.request.user.id;

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

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="dateLabel" [title]="dateLabel"
(closeButtonClicked)="dialogRef.close()" (closeButtonClicked)="onClose()"
/> />
<div class="flex-grow-1 overflow-auto px-3" mat-dialog-content> <div class="flex-grow-1 overflow-auto px-3" mat-dialog-content>
@ -20,7 +20,7 @@
@if (data.realizedProfit !== 0) { @if (data.realizedProfit !== 0) {
<div class="performance-metric"> <div class="performance-metric">
<div class="metric-label" i18n>Realized P&amp;L</div> <div class="metric-label" i18n>Dividend &amp; Interest</div>
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
@ -57,7 +57,7 @@
<td> <td>
<span <span
class="activity-type" class="activity-type"
[class]="activity.type?.toLowerCase()" [ngClass]="activity.type?.toLowerCase()"
> >
{{ activity.type }} {{ activity.type }}
</span> </span>
@ -119,5 +119,5 @@
<gf-dialog-footer <gf-dialog-footer
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
(closeButtonClicked)="dialogRef.close()" (closeButtonClicked)="onClose()"
/> />

32
apps/client/src/app/pages/portfolio/journal/journal-day-dialog/journal-day-dialog.component.scss

@ -63,3 +63,35 @@
color: #f97316; 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);
}
}
}

65
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Activity, User } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -25,6 +23,7 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -45,14 +44,14 @@ export interface JournalDayDialogData {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfActivitiesTableComponent,
GfDialogFooterComponent, GfDialogFooterComponent,
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule MatInputModule,
MatSnackBarModule
], ],
selector: 'gf-journal-day-dialog', selector: 'gf-journal-day-dialog',
styleUrls: ['./journal-day-dialog.component.scss'], styleUrls: ['./journal-day-dialog.component.scss'],
@ -62,12 +61,13 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy {
public activities: Activity[] = []; public activities: Activity[] = [];
public date: string; public date: string;
public dateLabel: string; public dateLabel: string;
public hasChanges = false;
public isLoadingActivities = true; public isLoadingActivities = true;
public isLoadingNote = true; public isLoadingNote = true;
public isSavingNote = false; public isSavingNote = false;
public note = ''; public note = '';
public user: User;
private originalNote = '';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -75,21 +75,17 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy {
@Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData, @Inject(MAT_DIALOG_DATA) public data: JournalDayDialogData,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<GfJournalDayDialogComponent>, public dialogRef: MatDialogRef<GfJournalDayDialogComponent>,
private userService: UserService private snackBar: MatSnackBar
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.date = this.data.date; this.date = this.data.date;
this.dateLabel = format(parseISO(this.date), 'EEEE, MMMM d, yyyy'); this.dateLabel = new Intl.DateTimeFormat(this.data.locale ?? 'en-US', {
weekday: 'long',
this.userService.stateChanged year: 'numeric',
.pipe(takeUntil(this.unsubscribeSubject)) month: 'long',
.subscribe((state) => { day: 'numeric'
if (state?.user) { }).format(parseISO(this.date));
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
this.loadActivities(); this.loadActivities();
this.loadNote(); this.loadNote();
@ -108,31 +104,55 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({
next: () => { next: () => {
this.hasChanges = true;
this.originalNote = this.note.trim();
this.isSavingNote = false; this.isSavingNote = false;
this.snackBar.open($localize`Note saved`, undefined, {
duration: 3000
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}, },
error: () => { error: () => {
this.isSavingNote = false; this.isSavingNote = false;
this.snackBar.open($localize`Failed to save note`, undefined, {
duration: 5000
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} else { } else if (this.originalNote) {
// Only call delete if there was an existing note
this.dataService this.dataService
.deleteJournalEntry({ date: this.date }) .deleteJournalEntry({ date: this.date })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({
next: () => { next: () => {
this.hasChanges = true;
this.originalNote = '';
this.isSavingNote = false; this.isSavingNote = false;
this.snackBar.open($localize`Note deleted`, undefined, {
duration: 3000
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}, },
error: () => { error: () => {
this.isSavingNote = false; this.isSavingNote = false;
this.snackBar.open($localize`Failed to delete note`, undefined, {
duration: 5000
});
this.changeDetectorRef.markForCheck(); 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
@ -141,11 +161,16 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy {
private loadActivities() { private loadActivities() {
this.isLoadingActivities = true; 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 this.dataService
.fetchActivities({ .fetchActivities({
filters: [], filters: [],
range: year as any,
skip: 0, skip: 0,
take: 50, take: 500,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc' sortDirection: 'desc'
}) })
@ -174,11 +199,13 @@ export class GfJournalDayDialogComponent implements OnInit, OnDestroy {
.subscribe({ .subscribe({
next: (entry) => { next: (entry) => {
this.note = entry?.note ?? ''; this.note = entry?.note ?? '';
this.originalNote = this.note;
this.isLoadingNote = false; this.isLoadingNote = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}, },
error: () => { error: () => {
this.note = ''; this.note = '';
this.originalNote = '';
this.isLoadingNote = false; this.isLoadingNote = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

5
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 realizedProfit: dayData?.realizedProfit ?? 0
} as JournalDayDialogData, } as JournalDayDialogData,
height: this.deviceType === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '100vw' : '50rem',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((result) => {
if (result?.hasChanges) {
this.loadJournalData(); this.loadJournalData();
}
}); });
} }

5
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 { Routes } from '@angular/router';
import { JournalPageComponent } from './journal-page.component'; import { JournalPageComponent } from './journal-page.component';
@ -5,6 +7,7 @@ import { JournalPageComponent } from './journal-page.component';
export const routes: Routes = [ export const routes: Routes = [
{ {
component: JournalPageComponent, component: JournalPageComponent,
path: '' path: '',
title: internalRoutes.portfolio.subRoutes.journal.title
} }
]; ];

6
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 { export class CreateOrUpdateJournalEntryDto {
@IsISO8601() @IsNotEmpty()
date: string;
@IsString() @IsString()
@MaxLength(10000) @MaxLength(10000)
note: string; note: string;

3
libs/common/src/lib/permissions.ts

@ -43,6 +43,7 @@ export const permissions = {
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
readAiPrompt: 'readAiPrompt', readAiPrompt: 'readAiPrompt',
readJournalEntry: 'readJournalEntry',
readMarketData: 'readMarketData', readMarketData: 'readMarketData',
readMarketDataOfMarkets: 'readMarketDataOfMarkets', readMarketDataOfMarkets: 'readMarketDataOfMarkets',
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
@ -96,6 +97,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readJournalEntry,
permissions.readMarketData, permissions.readMarketData,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
@ -144,6 +146,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteWatchlistItem, permissions.deleteWatchlistItem,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readJournalEntry,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
permissions.readWatchlist, permissions.readWatchlist,

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

@ -101,7 +101,7 @@
class="calendar-cell day-cell" class="calendar-cell day-cell"
[class.clickable]="day.isCurrentMonth" [class.clickable]="day.isCurrentMonth"
[class.today]="day.isToday" [class.today]="day.isToday"
[class]="getDayClass(day)" [ngClass]="getDayClass(day)"
(click)="onDayClick(day)" (click)="onDayClick(day)"
> >
<div class="day-number">{{ day.dayOfMonth }}</div> <div class="day-number">{{ day.dayOfMonth }}</div>
@ -111,8 +111,10 @@
</div> </div>
@if (day.data.activitiesCount > 0) { @if (day.data.activitiesCount > 0) {
<div class="day-activities"> <div class="day-activities">
{{ day.data.activitiesCount }} <span i18n>{day.data.activitiesCount, plural,
{{ day.data.activitiesCount === 1 ? 'trade' : 'trades' }} =1 {1 activity}
other {{{day.data.activitiesCount}} activities}
}</span>
</div> </div>
} }
@if (day.data.hasNote) { @if (day.data.hasNote) {

45
libs/ui/src/lib/journal-calendar/journal-calendar.component.ts

@ -10,7 +10,8 @@ import {
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
Output Output,
SimpleChanges
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
@ -21,6 +22,7 @@ import {
endOfMonth, endOfMonth,
format, format,
getDay, getDay,
isToday,
startOfMonth, startOfMonth,
subMonths subMonths
} from 'date-fns'; } from 'date-fns';
@ -58,10 +60,13 @@ export class GfJournalCalendarComponent implements OnChanges {
@Output() monthChanged = new EventEmitter<{ month: number; year: number }>(); @Output() monthChanged = new EventEmitter<{ month: number; year: number }>();
public calendarWeeks: CalendarDay[][] = []; public calendarWeeks: CalendarDay[][] = [];
public weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; public weekDays: string[] = [];
public get monthLabel(): 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 { public get winRate(): number {
@ -82,10 +87,16 @@ export class GfJournalCalendarComponent implements OnChanges {
}); });
} }
public ngOnChanges() { public ngOnChanges(changes: SimpleChanges) {
if (changes['days'] || changes['month'] || changes['year']) {
this.buildCalendar(); this.buildCalendar();
} }
if (changes['locale']) {
this.buildWeekDays();
}
}
public onPreviousMonth() { public onPreviousMonth() {
const prev = subMonths(new Date(this.year, this.month - 1), 1); const prev = subMonths(new Date(this.year, this.month - 1), 1);
this.monthChanged.emit({ this.monthChanged.emit({
@ -129,7 +140,7 @@ export class GfJournalCalendarComponent implements OnChanges {
} }
public formatCurrency(value: number): string { public formatCurrency(value: number): string {
if (value === 0 || value === undefined || value === null) { if (value === undefined || value === null) {
return ''; 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() { private buildCalendar() {
if (!this.weekDays.length) {
this.buildWeekDays();
}
const currentDate = new Date(this.year, this.month - 1); const currentDate = new Date(this.year, this.month - 1);
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate); const monthEnd = endOfMonth(currentDate);
const today = new Date();
const daysDataMap = new Map<string, JournalCalendarDataItem>(); const daysDataMap = new Map<string, JournalCalendarDataItem>();
@ -181,10 +209,7 @@ export class GfJournalCalendarComponent implements OnChanges {
date, date,
dayOfMonth: date.getDate(), dayOfMonth: date.getDate(),
isCurrentMonth: true, isCurrentMonth: true,
isToday: isToday: isToday(date)
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
}); });
} }

2
libs/ui/src/lib/services/data.service.ts

@ -432,7 +432,7 @@ export class DataService {
} }
public putJournalEntry({ date, note }: { date: string; note: string }) { 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 }) { public deleteJournalEntry({ date }: { date: string }) {

Loading…
Cancel
Save