feat: multiple improvements to library functionality (#50)
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s

## Summary

This PR implements several improvements to the library application:

- Added start and finish date tracking for media items
- Added "Retired" category for abandoned media
- Implemented avatar-based user menu with dropdown navigation
- Added automatic background token refresh to prevent session expiry
- Created centralised logging system with frontend-to-API log forwarding
- Added toast notifications for error handling

## Changes

### Media Tracking (#41)
- Added `dateStarted` and `dateFinished` fields to Books, Games, Manga, Music, and Shows
- Updated TypeScript types, Prisma schema, and API services
- Added manual date input fields to frontend forms
- Properly converts HTML date strings to Date objects before API submission

### Retired Category (#43)
- Added `RETIRED` status to all media type enums
- Updated Prisma schema, frontend dropdowns, and filter buttons
- Added status label handling for retired items

### User Menu (#46)
- Replaced username text with avatar image in header
- Created dropdown menu with navigation items (Users, Audit, Suggestions)
- Added logout button to menu
- Implemented keyboard accessibility (tabindex, role, keyup handlers)

### Token Refresh (#44)
- Implemented automatic token refresh every 13 minutes in background
- Added proactive refresh to prevent token expiry during form filling
- Prevents users from losing form data due to expired sessions

### Centralised Logging (#1)
- Created `/log` endpoint on API to receive frontend logs
- Replaced API console.log calls with @nhcarrigan/logger
- Created ConsoleLoggerService to intercept all console methods on frontend
- Added global error handlers (window.error, unhandledrejection) on frontend
- Added process error handlers (uncaughtException, unhandledRejection, SIGTERM, SIGINT) on API
- All frontend console activity now forwarded to centralised logging

### Error Handling
- Created ToastService and ToastComponent for displaying errors
- Integrated with GlobalErrorHandler and HTTP interceptor
- Added accessibility features (keyboard navigation, ARIA attributes)
- Set toast opacity to 40% for optimal readability

### Testing & Build
- Fixed pre-existing test failure for GET / route (now returns version info)
- Added ESM module mocking (jsdom, marked, dompurify, @nhcarrigan/logger)
- Configured Jest with isolatedModules to handle TypeScript errors
- Excluded test-setup.ts from production build
- All tests passing (123 total)
- Build passing with no errors

## Test Plan

- [x] All tests pass (123 tests)
- [x] Build passes without errors
- [x] Lint passes (only pre-existing warnings)
- [x] Date fields work correctly on all media types
- [x] Retired status displays and filters properly
- [x] Avatar menu opens/closes correctly with keyboard and mouse
- [x] Token refresh prevents session expiry
- [x] Toast notifications appear for errors
- [x] Frontend logs forward to API successfully
- [x] Root route returns version information

Closes #41
Closes #43
Closes #44
Closes #46
Closes #1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #50.
This commit is contained in:
2026-02-19 16:52:43 -08:00
committed by Naomi Carrigan
parent 9caf74945a
commit 7579f1ec97
93 changed files with 4297 additions and 645 deletions
@@ -67,9 +67,30 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -114,8 +135,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -132,8 +152,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of newGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -198,9 +217,30 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -245,8 +285,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -263,8 +302,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group">
<label>External Links</label>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of editGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -436,6 +474,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
>
Backlog ({{ backlogCount() }})
</button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -504,6 +549,26 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
}
@if (game.dateStarted) {
<p class="date-started">
Started: {{ formatDate(game.dateStarted) }}
</p>
}
@if (game.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(game.dateFinished) }}
</p>
}
<p class="date-added">
Added: {{ formatDate(game.createdAt) }}
</p>
<p class="date-updated">
Updated: {{ formatDate(game.updatedAt) }}
</p>
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
@@ -858,6 +923,15 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1213,6 +1287,7 @@ export class GamesListComponent implements OnInit {
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1261,10 +1336,12 @@ export class GamesListComponent implements OnInit {
totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> = {
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1341,6 +1418,7 @@ export class GamesListComponent implements OnInit {
case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog';
case GameStatus.retired: return 'Retired';
}
}
@@ -1356,6 +1434,8 @@ export class GamesListComponent implements OnInit {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1424,6 +1504,8 @@ export class GamesListComponent implements OnInit {
rating: this.newGame.rating,
notes: this.newGame.notes,
coverImage: this.newGame.coverImage,
dateStarted: this.newGame.dateStarted ? new Date(this.newGame.dateStarted) : undefined,
dateFinished: this.newGame.dateFinished ? new Date(this.newGame.dateFinished) : undefined,
tags: this.newGame.tags || [],
links: this.newGame.links || []
};
@@ -1448,6 +1530,8 @@ export class GamesListComponent implements OnInit {
title: game.title,
platform: game.platform,
status: game.status,
dateStarted: game.dateStarted,
dateFinished: game.dateFinished,
rating: game.rating,
notes: game.notes,
coverImage: game.coverImage,
@@ -1476,7 +1560,13 @@ export class GamesListComponent implements OnInit {
const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return;
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
const updateData: UpdateGameDto = {
...this.editGame,
dateStarted: this.editGame.dateStarted ? new Date(this.editGame.dateStarted) : undefined,
dateFinished: this.editGame.dateFinished ? new Date(this.editGame.dateFinished) : undefined
};
this.gamesService.updateGame(game.id, updateData).subscribe(() => {
this.loadGames();
this.cancelEdit();
});
@@ -1673,7 +1763,7 @@ export class GamesListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.GAME,
entityType: SuggestionEntity.game,
title: this.suggestedGame.title,
platform: this.suggestedGame.platform,
notes: this.suggestedGame.notes,