-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add read-only item view #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
CodeWithMa
merged 2 commits into
CodeWithMa:dev
from
CodeWithMaBot:feature/item-read-only-view
Aug 23, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
src/app/components/item-view/item-view.component.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: ` | ||
| <div class="max-w-[800px] mx-auto p-8"> | ||
| @if (item(); as currentItem) { | ||
| <div class="flex items-center justify-between mb-6"> | ||
| <h1 class="m-0">{{ currentItem.title }}</h1> | ||
| <div class="flex gap-2"> | ||
| <a | ||
| [routerLink]="['/items', currentItem.id, 'edit']" | ||
| class="px-6 py-3 bg-accent-primary text-white no-underline rounded font-medium hover:bg-accent-primary-hover" | ||
| >Edit Item</a | ||
| > | ||
| </div> | ||
| </div> | ||
|
|
||
| <div | ||
| class="border border-light-border dark:border-dark-border rounded-lg bg-light-bg-secondary dark:bg-dark-bg-secondary p-6 mb-6" | ||
| > | ||
| <div class="flex flex-col gap-6 sm:flex-row"> | ||
| <img | ||
| [src]="posterUrl()" | ||
| [alt]="currentItem.title + ' poster'" | ||
| class="w-full max-w-[180px] aspect-[2/3] object-cover rounded" | ||
| /> | ||
|
|
||
| <dl class="grid grid-cols-1 gap-4 flex-1 m-0 sm:grid-cols-2"> | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted">Type</dt> | ||
| <dd class="m-0 capitalize">{{ currentItem.type }}</dd> | ||
| </div> | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted">Status</dt> | ||
| <dd class="m-0 capitalize">{{ currentItem.status.replace('-', ' ') }}</dd> | ||
| </div> | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted">Group</dt> | ||
| <dd class="m-0">{{ groupName() ?? 'Ungrouped' }}</dd> | ||
| </div> | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted">Added</dt> | ||
| <dd class="m-0">{{ currentItem.createdAt | date: 'medium' }}</dd> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </div> | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted"> | ||
| Last watched | ||
| </dt> | ||
| <dd class="m-0"> | ||
| @if (hasWatchHistory()) { | ||
| <app-time-ago [date]="lastWatchedDate()" /> | ||
| } @else { | ||
| Never | ||
| } | ||
| </dd> | ||
| </div> | ||
| @if (currentItem.progress) { | ||
| <div> | ||
| <dt class="text-sm text-light-font-muted dark:text-dark-font-muted">Progress</dt> | ||
| <dd class="m-0 font-mono"> | ||
| S{{ currentItem.progress.season }}E{{ currentItem.progress.episode }} | ||
| </dd> | ||
| </div> | ||
| } | ||
| </dl> | ||
| </div> | ||
| </div> | ||
|
|
||
| <section class="mb-6"> | ||
| <h2 class="text-xl mb-3">Quick Actions</h2> | ||
| <div class="flex flex-wrap gap-2"> | ||
| @for (action of quickActions; track action.label) { | ||
| <button | ||
| type="button" | ||
| (click)="runAction(action.action)" | ||
| class="px-4 py-2 border border-light-border dark:border-dark-border rounded bg-light-bg-primary dark:bg-dark-bg-primary text-light-font dark:text-dark-font cursor-pointer hover:border-accent-primary transition-colors" | ||
| > | ||
| {{ action.label }} | ||
| </button> | ||
| } | ||
| </div> | ||
| </section> | ||
|
|
||
| <section> | ||
| <h2 class="text-xl mb-3">Watch History</h2> | ||
| @if (watchHistory().length === 0) { | ||
| <p | ||
| class="p-8 text-center bg-light-bg-secondary dark:bg-dark-bg-secondary rounded-lg border border-light-border dark:border-dark-border" | ||
| > | ||
| No watch history yet. | ||
| </p> | ||
| } @else { | ||
| <ol class="space-y-2 list-none p-0 m-0"> | ||
| @for (entry of watchHistory(); track entry.date) { | ||
| <li | ||
| class="flex items-center gap-4 px-5 py-3 bg-light-bg-secondary dark:bg-dark-bg-secondary rounded-lg border border-light-border dark:border-dark-border" | ||
| > | ||
| <span>{{ entry.date | date: 'medium' }}</span> | ||
| @if (entry.season) { | ||
| <span | ||
| class="text-xs bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded-full font-medium" | ||
| >S{{ entry.season }}E{{ entry.episode }}</span | ||
| > | ||
| } | ||
| </li> | ||
| } | ||
| </ol> | ||
| } | ||
| </section> | ||
| } @else { | ||
| <div class="text-center px-8 py-16"> | ||
| <h2 class="mb-4">Item not found</h2> | ||
| <a [routerLink]="['/items']">Back to Items</a> | ||
| </div> | ||
| } | ||
| </div> | ||
| `, | ||
| }) | ||
| 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<Item | null>(() => { | ||
| 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<void> { | ||
| 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()); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: CodeWithMa/watch-list
Length of output: 1779
🏁 Script executed:
Repository: CodeWithMa/watch-list
Length of output: 30076
🏁 Script executed:
Repository: CodeWithMa/watch-list
Length of output: 2300
🌐 Web query:
Angular 22 DATE_PIPE_DEFAULT_OPTIONS DatePipe timezone import package💡 Result:
In Angular, the DATE_PIPE_DEFAULT_OPTIONS injection token is used to configure default settings for the DatePipe, including the timezone [1][2][3]. This token is exported from the
@angular/commonpackage [4][5]. To use DATE_PIPE_DEFAULT_OPTIONS to set a default timezone, you provide it in your application's providers array [1][3]. The value should be an object that conforms to the DatePipeConfig interface [1][6][3]. Example implementation: import { DATE_PIPE_DEFAULT_OPTIONS } from '@angular/common'; providers: [ { provide: DATE_PIPE_DEFAULT_OPTIONS, useValue: { timezone: '-1200' } } ] When this token is configured, the DatePipe will use the specified timezone as the default if a specific timezone is not passed as the second argument to the pipe instance [1][2][7]. Explicitly providing a timezone in the DatePipe transform method will always take precedence over the default configured via this injection token [2][7].Citations:
Make the date expectation timezone-stable.
createdAtis10:00Z, so it remains April 1 in U.S. timezones but becomes March 31 in timezones at UTC−11 or UTC−12. ProvideDATE_PIPE_DEFAULT_OPTIONSwithtimezone: 'UTC'and import it from@angular/common.🤖 Prompt for AI Agents