generated from nhcarrigan/template
feat: add time tracking to all media types
Implements issue #57 with manual time entry functionality: - Added timeSpent field (stored in minutes) to all media models - Supports hours and minutes input in forms - Displays formatted time on media cards Database Schema: - Added timeSpent Int? field to Game, Book, Music, Show, and Manga models - Stored in minutes for consistency and easy calculation Shared Types: - Updated all media type interfaces with timeSpent? field - Added to CreateDto and main interfaces for all media types Frontend (Games - template for other types): - Added hour/minute input fields to Add and Edit forms - Split input with validation (minutes 0-59) - Auto-calculates total minutes on change - Formats display as "Xh Ym", "Xh", or "Ym" as appropriate - Green highlighted time display on cards with ⏱️ icon - Populates edit form from existing time data Implementation Notes: - Backend services require no changes (DTOs handle automatically) - Time conversion helpers for display and storage - Form state properly resets time fields - Edit mode correctly splits minutes back to hours/minutes Next Steps: - Apply same UI pattern to Books, Music, Shows, Manga - Add time statistics to user profiles - Consider aggregate time tracking views
This commit is contained in:
@@ -34,6 +34,7 @@ model Game {
|
|||||||
links Link[]
|
links Link[]
|
||||||
series String?
|
series String?
|
||||||
seriesOrder Int? @db.Int
|
seriesOrder Int? @db.Int
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -62,6 +63,7 @@ model Book {
|
|||||||
links Link[]
|
links Link[]
|
||||||
series String?
|
series String?
|
||||||
seriesOrder Int? @db.Int
|
seriesOrder Int? @db.Int
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -89,6 +91,7 @@ model Music {
|
|||||||
coverArt String?
|
coverArt String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -135,6 +138,7 @@ model Show {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
@@ -168,6 +172,7 @@ model Manga {
|
|||||||
coverImage String?
|
coverImage String?
|
||||||
tags String[]
|
tags String[]
|
||||||
links Link[]
|
links Link[]
|
||||||
|
timeSpent Int? @db.Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|||||||
@@ -104,6 +104,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="timeHours">Time Spent (Hours)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="timeHours"
|
||||||
|
[(ngModel)]="newGameTimeHours"
|
||||||
|
name="timeHours"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
(ngModelChange)="updateNewGameTimeSpent()"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="timeMinutes"
|
||||||
|
[(ngModel)]="newGameTimeMinutes"
|
||||||
|
name="timeMinutes"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
placeholder="0"
|
||||||
|
(ngModelChange)="updateNewGameTimeSpent()"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -277,6 +305,34 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-timeHours">Time Spent (Hours)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="edit-timeHours"
|
||||||
|
[(ngModel)]="editGameTimeHours"
|
||||||
|
name="timeHours"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
(ngModelChange)="updateEditGameTimeSpent()"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="edit-timeMinutes"
|
||||||
|
[(ngModel)]="editGameTimeMinutes"
|
||||||
|
name="timeMinutes"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
placeholder="0"
|
||||||
|
(ngModelChange)="updateEditGameTimeSpent()"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -574,6 +630,12 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (game.timeSpent) {
|
||||||
|
<p class="time-spent">
|
||||||
|
⏱️ Time Played: {{ formatTimeSpent(game.timeSpent) }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
<app-like-button
|
<app-like-button
|
||||||
entityType="game"
|
entityType="game"
|
||||||
[entityId]="game.id"
|
[entityId]="game.id"
|
||||||
@@ -741,6 +803,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -942,6 +1011,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-spent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.date-started,
|
.date-started,
|
||||||
.date-finished,
|
.date-finished,
|
||||||
.date-added,
|
.date-added,
|
||||||
@@ -1369,6 +1445,12 @@ export class GamesListComponent implements OnInit {
|
|||||||
|
|
||||||
editGame: Partial<UpdateGameDto> = {};
|
editGame: Partial<UpdateGameDto> = {};
|
||||||
|
|
||||||
|
// Time tracking state
|
||||||
|
newGameTimeHours = 0;
|
||||||
|
newGameTimeMinutes = 0;
|
||||||
|
editGameTimeHours = 0;
|
||||||
|
editGameTimeMinutes = 0;
|
||||||
|
|
||||||
// Tags and links input state
|
// Tags and links input state
|
||||||
newTagInput = '';
|
newTagInput = '';
|
||||||
editTagInput = '';
|
editTagInput = '';
|
||||||
@@ -1461,6 +1543,8 @@ export class GamesListComponent implements OnInit {
|
|||||||
tags: [],
|
tags: [],
|
||||||
links: []
|
links: []
|
||||||
};
|
};
|
||||||
|
this.newGameTimeHours = 0;
|
||||||
|
this.newGameTimeMinutes = 0;
|
||||||
this.newGameImagePreview.set(null);
|
this.newGameImagePreview.set(null);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
this.newTagInput = '';
|
this.newTagInput = '';
|
||||||
@@ -1468,6 +1552,16 @@ export class GamesListComponent implements OnInit {
|
|||||||
this.newLinkUrl = '';
|
this.newLinkUrl = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNewGameTimeSpent() {
|
||||||
|
const totalMinutes = (this.newGameTimeHours * 60) + this.newGameTimeMinutes;
|
||||||
|
this.newGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEditGameTimeSpent() {
|
||||||
|
const totalMinutes = (this.editGameTimeHours * 60) + this.editGameTimeMinutes;
|
||||||
|
this.editGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
addTag(target: 'new' | 'edit') {
|
addTag(target: 'new' | 'edit') {
|
||||||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
@@ -1557,8 +1651,17 @@ export class GamesListComponent implements OnInit {
|
|||||||
tags: [...(game.tags || [])],
|
tags: [...(game.tags || [])],
|
||||||
links: [...(game.links || [])],
|
links: [...(game.links || [])],
|
||||||
series: game.series,
|
series: game.series,
|
||||||
seriesOrder: game.seriesOrder
|
seriesOrder: game.seriesOrder,
|
||||||
|
timeSpent: game.timeSpent
|
||||||
};
|
};
|
||||||
|
// Populate time fields from existing timeSpent
|
||||||
|
if (game.timeSpent) {
|
||||||
|
this.editGameTimeHours = Math.floor(game.timeSpent / 60);
|
||||||
|
this.editGameTimeMinutes = game.timeSpent % 60;
|
||||||
|
} else {
|
||||||
|
this.editGameTimeHours = 0;
|
||||||
|
this.editGameTimeMinutes = 0;
|
||||||
|
}
|
||||||
this.editGameImagePreview.set(game.coverImage || null);
|
this.editGameImagePreview.set(game.coverImage || null);
|
||||||
this.showAddForm.set(false);
|
this.showAddForm.set(false);
|
||||||
this.imageError.set(null);
|
this.imageError.set(null);
|
||||||
@@ -1650,6 +1753,19 @@ export class GamesListComponent implements OnInit {
|
|||||||
return new Date(date).toLocaleDateString();
|
return new Date(date).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatTimeSpent(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${mins}m`;
|
||||||
|
} else if (mins === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else {
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleComments(gameId: string) {
|
toggleComments(gameId: string) {
|
||||||
const expanded = this.expandedComments();
|
const expanded = this.expandedComments();
|
||||||
const isCurrentlyExpanded = expanded[gameId];
|
const isCurrentlyExpanded = expanded[gameId];
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Book {
|
|||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
series?: string;
|
series?: string;
|
||||||
seriesOrder?: number;
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ interface CreateBookDto {
|
|||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
series?: string;
|
series?: string;
|
||||||
seriesOrder?: number;
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateBookDto extends Partial<CreateBookDto> {
|
interface UpdateBookDto extends Partial<CreateBookDto> {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Game {
|
|||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
series?: string;
|
series?: string;
|
||||||
seriesOrder?: number;
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,7 @@ interface CreateGameDto {
|
|||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
series?: string;
|
series?: string;
|
||||||
seriesOrder?: number;
|
seriesOrder?: number;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateGameDto extends Partial<CreateGameDto> {
|
interface UpdateGameDto extends Partial<CreateGameDto> {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface Manga {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ interface CreateMangaDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
interface UpdateMangaDto extends Partial<CreateMangaDto> {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface Music {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ interface CreateMusicDto {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
interface UpdateMusicDto extends Partial<CreateMusicDto> {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface Show {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
links: Array<Link>;
|
links: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,7 @@ interface CreateShowDto {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
tags?: Array<string>;
|
tags?: Array<string>;
|
||||||
links?: Array<Link>;
|
links?: Array<Link>;
|
||||||
|
timeSpent?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateShowDto extends Partial<CreateShowDto> {
|
interface UpdateShowDto extends Partial<CreateShowDto> {
|
||||||
|
|||||||
Reference in New Issue
Block a user