diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 55171e3..8fb6dc7 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -17,6 +17,11 @@ export const routes: Routes = [ }, { path: 'items/:id', + loadComponent: () => + import('./components/item-view/item-view.component').then((m) => m.ItemViewComponent), + }, + { + path: 'items/:id/edit', loadComponent: () => import('./components/item-detail/item-detail.component').then((m) => m.ItemDetailComponent), }, diff --git a/src/app/components/item-view/item-view.component.spec.ts b/src/app/components/item-view/item-view.component.spec.ts new file mode 100644 index 0000000..337762d --- /dev/null +++ b/src/app/components/item-view/item-view.component.spec.ts @@ -0,0 +1,114 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { GroupService } from '../../services/group.service'; +import { ImageStorageService } from '../../services/image-storage.service'; +import { WatchListService } from '../../services/watch-list.service'; +import { Item } from '../../models/item.model'; +import { vi } from 'vitest'; +import { of } from 'rxjs'; +import { ItemViewComponent } from './item-view.component'; + +const item: Item = { + id: 'series-1', + title: 'Test Series', + type: 'series', + groupId: 'group-1', + status: 'in-progress', + progress: { + season: 2, + episode: 3, + seasons: [], + }, + watchHistory: [ + { date: '2026-05-01T10:00:00.000Z' }, + { date: '2026-05-02T10:00:00.000Z', season: 1, episode: 1 }, + ], + createdAt: '2026-04-01T10:00:00.000Z', +}; + +describe('ItemViewComponent', () => { + function configure(items: Item[]): void { + TestBed.configureTestingModule({ + providers: [ + { provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: { timezone: 'UTC' } }, + { + provide: ActivatedRoute, + useValue: { paramMap: of(convertToParamMap({ id: items[0]?.id ?? '' })) }, + }, + { + provide: WatchListService, + useValue: { + items: signal(items), + markWatched: vi.fn(), + markCompleted: vi.fn(), + markStarted: vi.fn(), + markPaused: vi.fn(), + markDropped: vi.fn(), + }, + }, + { + provide: GroupService, + useValue: { groups: signal([{ id: 'group-1', name: 'Favourites', order: 0 }]) }, + }, + { provide: ImageStorageService, useValue: { getUrl: vi.fn(async () => null) } }, + ], + }); + } + + it('renders metadata and newest-first item history', async () => { + configure([item]); + const fixture = TestBed.createComponent(ItemViewComponent); + fixture.detectChanges(); + await fixture.whenStable(); + + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).toContain('Test Series'); + expect(element.textContent).toContain('Favourites'); + expect(element.textContent).toContain('Apr 1, 2026'); + expect(element.textContent).toContain('S2E3'); + expect(element.textContent).toContain('S1E1'); + + const historyEntries = [...element.querySelectorAll('ol li')]; + expect(historyEntries[0].textContent).toContain('May 2, 2026'); + }); + + it('shows an empty-history state', async () => { + configure([{ ...item, watchHistory: [] }]); + const fixture = TestBed.createComponent(ItemViewComponent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('No watch history yet.'); + }); + + it('shows not found for a missing item', async () => { + configure([]); + const fixture = TestBed.createComponent(ItemViewComponent); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('Item not found'); + }); + + it('invokes matching quick actions', async () => { + configure([item]); + const fixture = TestBed.createComponent(ItemViewComponent); + fixture.detectChanges(); + await fixture.whenStable(); + + const service = TestBed.inject(WatchListService); + fixture.componentInstance.runAction('watched'); + fixture.componentInstance.runAction('completed'); + fixture.componentInstance.runAction('started'); + fixture.componentInstance.runAction('paused'); + fixture.componentInstance.runAction('dropped'); + + expect(service.markWatched).toHaveBeenCalledWith(item.id); + expect(service.markCompleted).toHaveBeenCalledWith(item.id); + expect(service.markStarted).toHaveBeenCalledWith(item.id); + expect(service.markPaused).toHaveBeenCalledWith(item.id); + expect(service.markDropped).toHaveBeenCalledWith(item.id); + }); +}); diff --git a/src/app/components/item-view/item-view.component.ts b/src/app/components/item-view/item-view.component.ts new file mode 100644 index 0000000..0c8f41a --- /dev/null +++ b/src/app/components/item-view/item-view.component.ts @@ -0,0 +1,204 @@ +import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { WatchListService } from '../../services/watch-list.service'; +import { GroupService } from '../../services/group.service'; +import { ImageStorageService } from '../../services/image-storage.service'; +import { Item } from '../../models/item.model'; +import { TimeAgoComponent } from '../time-ago/time-ago.component'; +import { getMostRecentWatchDate } from '../../utils/progress.utils'; +import { getPlaceholderUrl } from '../../utils/tmdb-image.utils'; + +type QuickAction = 'watched' | 'completed' | 'started' | 'paused' | 'dropped'; + +@Component({ + selector: 'app-item-view', + imports: [DatePipe, RouterLink, TimeAgoComponent], + template: ` +
+ @if (item(); as currentItem) { +
+

{{ currentItem.title }}

+
+ Edit Item +
+
+ +
+
+ + +
+
+
Type
+
{{ currentItem.type }}
+
+
+
Status
+
{{ currentItem.status.replace('-', ' ') }}
+
+
+
Group
+
{{ groupName() ?? 'Ungrouped' }}
+
+
+
Added
+
{{ currentItem.createdAt | date: 'medium' }}
+
+
+
+ Last watched +
+
+ @if (hasWatchHistory()) { + + } @else { + Never + } +
+
+ @if (currentItem.progress) { +
+
Progress
+
+ S{{ currentItem.progress.season }}E{{ currentItem.progress.episode }} +
+
+ } +
+
+
+ +
+

Quick Actions

+
+ @for (action of quickActions; track action.label) { + + } +
+
+ +
+

Watch History

+ @if (watchHistory().length === 0) { +

+ No watch history yet. +

+ } @else { +
    + @for (entry of watchHistory(); track entry.date) { +
  1. + {{ entry.date | date: 'medium' }} + @if (entry.season) { + S{{ entry.season }}E{{ entry.episode }} + } +
  2. + } +
+ } +
+ } @else { +
+

Item not found

+ Back to Items +
+ } +
+ `, +}) +export class ItemViewComponent { + private route = inject(ActivatedRoute); + private imageStorage = inject(ImageStorageService); + private watchListService = inject(WatchListService); + private groupService = inject(GroupService); + private destroyRef = inject(DestroyRef); + private destroyed = false; + + readonly quickActions: readonly { label: string; action: QuickAction }[] = [ + { label: 'Mark Watched', action: 'watched' }, + { label: 'Mark Completed', action: 'completed' }, + { label: 'Start', action: 'started' }, + { label: 'Pause', action: 'paused' }, + { label: 'Drop', action: 'dropped' }, + ]; + + readonly paramMap = toSignal(this.route.paramMap); + + readonly item = computed(() => { + const id = this.paramMap()?.get('id'); + return id ? (this.watchListService.items().find((item) => item.id === id) ?? null) : null; + }); + readonly groupName = computed(() => { + const item = this.item(); + return item + ? (this.groupService.groups().find((group) => group.id === item.groupId)?.name ?? null) + : null; + }); + readonly lastWatchedDate = computed(() => { + const item = this.item(); + return item && this.hasWatchHistory() ? getMostRecentWatchDate(item) : ''; + }); + readonly hasWatchHistory = computed(() => (this.item()?.watchHistory.length ?? 0) > 0); + readonly posterUrl = signal(getPlaceholderUrl()); + readonly watchHistory = computed(() => + [...(this.item()?.watchHistory ?? [])].sort( + (left, right) => new Date(right.date).getTime() - new Date(left.date).getTime(), + ), + ); + + constructor() { + effect(() => void this.loadPoster(this.item()?.posterId)); + this.destroyRef.onDestroy(() => { + this.destroyed = true; + const finalUrl = this.posterUrl(); + if (finalUrl.startsWith('blob:')) URL.revokeObjectURL(finalUrl); + }); + } + + runAction(action: QuickAction): void { + const item = this.item(); + if (!item) return; + + if (action === 'watched') this.watchListService.markWatched(item.id); + else if (action === 'completed') this.watchListService.markCompleted(item.id); + else if (action === 'started') this.watchListService.markStarted(item.id); + else if (action === 'paused') this.watchListService.markPaused(item.id); + else this.watchListService.markDropped(item.id); + } + + private async loadPoster(id: string | undefined): Promise { + const url = await this.imageStorage.getUrl(id); + if (this.destroyed || id !== this.item()?.posterId) { + if (url) URL.revokeObjectURL(url); + return; + } + + const previous = this.posterUrl(); + if (previous.startsWith('blob:')) URL.revokeObjectURL(previous); + this.posterUrl.set(url ?? getPlaceholderUrl()); + } +}