Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/app/components/home/home.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { Item } from '../../models/item.model';
import { RoundRobinService } from '../../services/round-robin.service';
import { WatchListService } from '../../services/watch-list.service';
import { HomeComponent } from './home.component';

describe('HomeComponent', () => {
const items: Item[] = [
{
id: 'paused-movie',
title: 'Paused Movie',
type: 'movie',
groupId: 'ungrouped',
status: 'paused',
watchHistory: [],
createdAt: '2026-05-02T10:00:00.000Z',
},
{
id: 'backlog-item',
title: 'Backlog Series',
type: 'series',
groupId: 'ungrouped',
status: 'not-started',
watchHistory: [],
createdAt: '2026-05-01T10:00:00.000Z',
},
];

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: RoundRobinService,
useValue: { nextSeries: signal(null), nextMovie: signal(null) },
},
{
provide: WatchListService,
useValue: {
items: signal(items),
inProgressSeries: signal([]),
markStarted: vi.fn(),
markDropped: vi.fn(),
},
},
],
});
});

it('shows paused movies separately from backlog', async () => {
const fixture = TestBed.createComponent(HomeComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

const sections = Array.from(
fixture.nativeElement.querySelectorAll('h2'),
) as HTMLHeadingElement[];
const pausedSection = sections.find((section) => section.textContent?.trim() === 'Paused');
const pausedContainer = pausedSection?.parentElement;

expect(pausedContainer?.textContent).toContain('Paused Movie');
expect(pausedContainer?.textContent).not.toContain('Backlog Series');
});

it('resumes and drops paused items', () => {
const fixture = TestBed.createComponent(HomeComponent);
const watchListService = TestBed.inject(WatchListService);

fixture.componentInstance.resumePausedItem('paused-movie');
fixture.componentInstance.dropPausedItem('paused-movie');

expect(watchListService.markStarted).toHaveBeenCalledWith('paused-movie');
expect(watchListService.markDropped).toHaveBeenCalledWith('paused-movie');
});

it('shows an empty state when no items are paused', async () => {
TestBed.overrideProvider(WatchListService, {
useValue: {
items: signal([]),
inProgressSeries: signal([]),
markStarted: vi.fn(),
markDropped: vi.fn(),
},
});
const fixture = TestBed.createComponent(HomeComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('No items paused');
});
});
64 changes: 63 additions & 1 deletion src/app/components/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { Item, ItemType } from '../../models/item.model';
(click)="markItem(nextMovie, 'paused')"
class="px-3 py-1.5 border border-light-border dark:border-dark-border rounded bg-light-bg-secondary dark:bg-dark-bg-secondary text-light-font dark:text-dark-font cursor-pointer text-sm hover:bg-light-bg-tertiary dark:hover:bg-dark-bg-tertiary"
>
Move to backlog
Pause
</button>
<button
(click)="markItem(nextMovie, 'dropped')"
Expand Down Expand Up @@ -161,6 +161,53 @@ import { Item, ItemType } from '../../models/item.model';
</p>
}
</div>

<div class="border-t border-light-border dark:border-dark-border pt-8 mt-8">
<h2 class="text-xl mb-4 text-light-font-secondary dark:text-dark-font-secondary">Paused</h2>
@if (pausedItems().length > 0) {
<div
class="overflow-hidden rounded-lg border border-light-border dark:border-dark-border bg-light-bg-secondary dark:bg-dark-bg-secondary shadow-light dark:shadow-dark"
>
@for (item of pausedItems(); track item.id) {
<div
class="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between border-b border-light-border dark:border-dark-border last:border-b-0"
>
<div class="min-w-0 flex items-center gap-3">
<a
[routerLink]="['/items', item.id]"
class="truncate no-underline text-base font-medium text-light-font dark:text-dark-font hover:text-accent-primary"
>{{ item.title }}</a
>
<span
class="shrink-0 rounded-full bg-light-bg-tertiary dark:bg-dark-bg-tertiary px-2 py-1 text-xs capitalize text-light-font-secondary dark:text-dark-font-secondary"
>{{ item.type }}</span
>
</div>
<div class="flex shrink-0 gap-2">
<button
(click)="resumePausedItem(item.id)"
class="px-3 py-1.5 border border-light-border dark:border-dark-border rounded bg-light-bg-secondary dark:bg-dark-bg-secondary text-light-font dark:text-dark-font cursor-pointer text-sm hover:bg-light-bg-tertiary dark:hover:bg-dark-bg-tertiary"
>
Resume
</button>
<button
(click)="dropPausedItem(item.id)"
class="px-3 py-1.5 border border-light-border dark:border-dark-border rounded bg-light-bg-secondary dark:bg-dark-bg-secondary text-light-font dark:text-dark-font cursor-pointer text-sm hover:bg-light-bg-tertiary dark:hover:bg-dark-bg-tertiary"
>
Drop
</button>
</div>
</div>
}
</div>
} @else {
<p
class="p-8 text-center text-light-font-muted dark:text-dark-font-muted bg-light-bg-primary dark:bg-dark-bg-primary rounded-lg"
>
No items paused
</p>
}
</div>
</div>
`,
})
Expand All @@ -177,6 +224,13 @@ export class HomeComponent {
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
);

pausedItems = computed(() =>
this.watchListService
.items()
.filter((item) => item.status === 'paused')
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
);

protected hasSeries = computed(() => this.hasItemType('series'));
protected hasMovies = computed(() => this.hasItemType('movie'));
protected hasInProgressSeries = computed(
Expand All @@ -203,6 +257,14 @@ export class HomeComponent {
this.watchListService.markDropped(itemId);
}

resumePausedItem(itemId: string): void {
this.watchListService.markStarted(itemId);
}

dropPausedItem(itemId: string): void {
this.watchListService.markDropped(itemId);
}

private hasItemType(type: ItemType): boolean {
return this.watchListService.items().some((item) => item.type === type);
}
Expand Down
28 changes: 28 additions & 0 deletions src/app/components/item-form/item-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,34 @@ describe('ItemFormComponent', () => {
expect(fixture.componentInstance.formValue().seasons[0].firstEpisodeAirDate).toBe('2026-05-01');
});

it('allows a movie to remain paused', async () => {
const fixture = TestBed.createComponent(ItemFormComponent);
fixture.componentRef.setInput('groups', groups);
fixture.componentRef.setInput('initialValue', {
title: 'Paused Movie',
type: 'series',
groupId: 'ungrouped',
status: 'paused',
season: 2,
episode: 3,
seasons: [{ seasonNumber: 2, totalEpisodes: 10 }],
startImmediately: false,
});

fixture.detectChanges();
await fixture.whenStable();

fixture.componentInstance.setType('movie');
await fixture.whenStable();

expect(fixture.componentInstance.formValue()).toMatchObject({
title: 'Paused Movie',
type: 'movie',
status: 'paused',
});
expect(fixture.componentInstance.itemStatuses).toContain('paused');
});

it('applies an autofill patch once when the patch id changes', async () => {
const fixture = TestBed.createComponent(ItemFormComponent);
fixture.componentRef.setInput('groups', groups);
Expand Down
18 changes: 3 additions & 15 deletions src/app/components/item-form/item-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Group } from '../../models/group.model';
import { SeasonInfo } from '../../models/item.model';
import { ItemStatus, SeasonInfo } from '../../models/item.model';
import { TmdbSuggestion } from '../../models/tmdb-suggestion.model';
import {
createDefaultItemFormValue,
Expand Down Expand Up @@ -156,7 +156,7 @@ export interface ItemFormAutofillPatch {
<div class="mb-6">
<span class="block mb-2 font-medium text-light-font dark:text-dark-font">Status:</span>
<div class="flex flex-wrap gap-3">
@for (status of itemStatuses(); track status) {
@for (status of itemStatuses; track status) {
<button
type="button"
(click)="updateStatus(status)"
Expand Down Expand Up @@ -272,13 +272,7 @@ export class ItemFormComponent {
readonly suggestionSelected = output<TmdbSuggestion>();

readonly itemTypes = ITEM_TYPES;
readonly itemStatuses = computed(() => {
const type = this.formValue().type;
if (type === 'movie') {
return ITEM_STATUSES.filter((s) => s !== 'paused');
}
return ITEM_STATUSES;
});
readonly itemStatuses: readonly ItemStatus[] = ITEM_STATUSES;
readonly itemTypeLabels = ITEM_TYPE_LABELS;
readonly itemStatusLabels = ITEM_STATUS_LABELS;

Expand Down Expand Up @@ -328,9 +322,6 @@ export class ItemFormComponent {
...value,
type,
});
if (next.type === 'movie' && next.status === 'paused') {
return { ...next, status: 'not-started' };
}
return next;
});
}
Expand Down Expand Up @@ -416,9 +407,6 @@ export class ItemFormComponent {
...value,
...patch,
});
if (next.type === 'movie' && next.status === 'paused') {
return { ...next, status: 'not-started' };
}
return next;
});
}
Expand Down
36 changes: 31 additions & 5 deletions src/app/domain/item-form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ describe('item-form helpers', () => {
]);
});

it('preserves a paused status when building a movie mutation input', () => {
const mutationInput = buildItemMutationInput({
title: 'Paused Movie',
type: 'movie',
groupId: 'ungrouped',
status: 'paused',
season: 1,
episode: 1,
seasons: [],
startImmediately: false,
});

expect(mutationInput.status).toBe('paused');
});

it('disables the add-flow override when submitting with a status picker', () => {
expect(
prepareSubmittedItemFormValue(
Expand Down Expand Up @@ -126,15 +141,26 @@ describe('item-form helpers', () => {
seasons: [{ seasonNumber: 4, totalEpisodes: 12 }],
startImmediately: false,
}),
).toEqual({
title: 'Movie Night',
type: 'movie',
groupId: 'ungrouped',
).toMatchObject({
status: 'completed',
season: 1,
episode: 1,
seasons: [],
startImmediately: false,
});
});

it('preserves paused status when normalizing movies', () => {
expect(
normalizeFormValueForType({
title: 'Paused Movie',
type: 'movie',
groupId: 'ungrouped',
status: 'paused',
season: 3,
episode: 4,
seasons: [{ seasonNumber: 3, totalEpisodes: 8 }],
startImmediately: false,
}),
).toMatchObject({ status: 'paused', season: 1, episode: 1, seasons: [] });
});
});
7 changes: 1 addition & 6 deletions src/app/domain/item-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,11 @@ export function createItemFormValue(item: Item): ItemFormValue {
export function buildItemMutationInput(formValue: ItemFormValue): ItemMutationInput {
const sortedSeasons = [...formValue.seasons].sort((a, b) => a.seasonNumber - b.seasonNumber);
const rawStatus: ItemStatus = formValue.startImmediately ? 'in-progress' : formValue.status;
const status: ItemStatus =
formValue.type === 'movie' && rawStatus === 'paused' ? 'not-started' : rawStatus;
return {
title: formValue.title.trim(),
type: formValue.type,
groupId: formValue.groupId,
status,
status: rawStatus,
progress:
formValue.type === 'series'
? {
Expand Down Expand Up @@ -90,8 +88,5 @@ export function normalizeFormValueForType(formValue: ItemFormValue): ItemFormVal
episode: 1,
seasons: [],
};
if (normalized.status === 'paused') {
normalized.status = 'not-started';
}
return normalized;
}
4 changes: 2 additions & 2 deletions src/app/services/watch-list.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,14 @@ describe('WatchListService', () => {
expect(storageService.getData().items['i1'].status).toBe('paused');
});

it('moves a movie to backlog when paused', () => {
it('marks a movie as paused', () => {
saveData({
items: { i1: createItem({ id: 'i1', type: 'movie', status: 'in-progress' }) },
});

service.markPaused('i1');

expect(storageService.getData().items['i1'].status).toBe('not-started');
expect(storageService.getData().items['i1'].status).toBe('paused');
});

it('does nothing when the item does not exist', () => {
Expand Down
3 changes: 1 addition & 2 deletions src/app/services/watch-list.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,9 @@ export class WatchListService {
const item = this.getItemById(itemId);
if (!item) return;

const nextStatus = item.type === 'movie' ? 'not-started' : 'paused';
this.persistItemUpdate({
...item,
status: nextStatus,
status: 'paused',
});
}

Expand Down
Loading