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
40 changes: 32 additions & 8 deletions src/lib/components/media/ContinueWatching.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Icon from '$lib/components/ui/Icon.svelte';
import ScrollRail from '$lib/components/ui/ScrollRail.svelte';
import SeasonTracker from './SeasonTracker.svelte';
import { enhance } from '$app/forms';
import { getSeasonProgress } from '$lib/domain/progress';
import { progressNote } from '$lib/domain/episodes';
import { posterUrl } from '$lib/format/tmdb-image';
Expand All @@ -22,9 +23,11 @@
items: SavedTitle[];
onSelect: (item: SavedTitle) => void;
onSetSeasons: (item: SavedTitle) => SubmitFunction;
/** Finishes a show the season stepper does not apply to. */
onToggle: (item: SavedTitle) => SubmitFunction;
}

let { items, onSelect, onSetSeasons }: Props = $props();
let { items, onSelect, onSetSeasons, onToggle }: Props = $props();
</script>

{#if items.length > 0}
Expand Down Expand Up @@ -80,13 +83,34 @@
{/if}
</div>

<SeasonTracker
itemId={item.id}
title={item.title}
{progress}
onSubmit={onSetSeasons(item)}
variant="rail"
/>
<!--
A show with one aired season has no stepper to offer — a 0-to-1
counter is friction, and drawing one anyway printed "0/0" beside an
empty bar for a title the note above had just placed at S1E4. It
gets the card's own control instead: the one thing left to say
about a single season is that it is finished.
-->
{#if progress.trackable}
<SeasonTracker
itemId={item.id}
title={item.title}
{progress}
onSubmit={onSetSeasons(item)}
variant="rail"
/>
{:else}
<form method="POST" action="?/toggleWatched" use:enhance={onToggle(item)}>
<input type="hidden" name="id" value={item.id} />
<button
type="submit"
aria-label={`Mark ${item.title} as watched`}
class="flex min-h-11 w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl bg-mint/15 px-2 text-xs font-semibold text-mint transition-colors duration-200 hover:bg-mint/25 active:scale-[0.98]"
>
<Icon name="check" size={14} stroke={2.5} />
<span class="truncate">Watched</span>
</button>
</form>
{/if}
</div>
</article>
{/each}
Expand Down
94 changes: 94 additions & 0 deletions src/lib/components/media/ContinueWatching.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ContinueWatching from './ContinueWatching.svelte';
import type { SavedTitle } from '$lib/domain/watchlist';

/**
* Which control the rail hands each show it is given.
*
* The rail draws whatever "in progress" sends it, and that used to be
* multi-season shows alone — so it assumed the season stepper always applied.
* Once a part-watched single season counted as in progress too, the assumption
* showed: a show with one aired season has no stepper to draw, and drawing one
* anyway printed "0/0" beside an empty bar. The choice between the two controls
* is what this pins.
*/
function item(over: Partial<SavedTitle> = {}): SavedTitle {
return {
id: 'item-1',
tmdbId: 1,
mediaType: 'tv',
title: 'Lanterns',
posterPath: null,
releaseDate: '2026-08-16',
voteAverage: 8.2,
watched: false,
seasonsSeen: 0,
episodesIntoSeason: 3,
totalSeasons: 1,
airedSeasons: 1,
nextSeasonNumber: null,
nextSeasonAirDate: null,
watchedAt: null,
...over
};
}

function rail(over: Partial<SavedTitle> = {}) {
const container = document.createElement('div');
document.body.appendChild(container);

render(ContinueWatching, {
props: {
items: [item(over)],
onSelect: vi.fn(),
onSetSeasons: () => vi.fn(),
onToggle: () => vi.fn()
},
target: container
});

return container;
}

/** The season stepper, which submits an absolute target season. */
const stepper = (root: HTMLElement) => root.querySelector('form[action*="setSeasons"]');

/** The plain watched toggle that stands in for it. */
const toggle = (root: HTMLElement) => root.querySelector('form[action*="toggleWatched"]');

describe('ContinueWatching — which control a show gets', () => {
it('gives a multi-season show the season stepper', () => {
const root = rail({ title: 'Breaking Bad', seasonsSeen: 2, totalSeasons: 5, airedSeasons: 5 });

expect(stepper(root)).not.toBeNull();
expect(toggle(root)).toBeNull();
});

/** The regression: one aired season, so there is no stepper to draw. */
it('gives a single-season show the watched toggle instead', () => {
const root = rail();

expect(stepper(root)).toBeNull();
expect(toggle(root)).not.toBeNull();
});

/**
* A second season that is only announced does not raise the ceiling, so this
* is the single-season case wearing a larger total.
*/
it('treats an announced second season as the single-season case', () => {
const root = rail({ totalSeasons: 2, nextSeasonNumber: 2, nextSeasonAirDate: '2027-01-15' });

expect(stepper(root)).toBeNull();
expect(toggle(root)).not.toBeNull();
});

// Whichever control it gets, the rail's answer to "where am I" is the note.
it('names the next episode either way', () => {
expect(rail().textContent).toContain('Next: S1E4');
expect(
rail({ seasonsSeen: 2, episodesIntoSeason: 0, totalSeasons: 5, airedSeasons: 5 }).textContent
).toContain('Next: S3E1');
});
});
16 changes: 11 additions & 5 deletions src/lib/domain/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,19 @@ export interface SeasonProgress {
}

/**
* How many seasons an entry can have watched, given what has aired.
* How many seasons an entry can have watched, given what has aired — or null
* when the answer is unknown, which for a film it always is.
*
* Falls back to the total only when the aired count has not been resolved yet
* (an entry saved before air dates were tracked). That is the pre-existing
* behaviour, and the read-path backfill replaces it on the next visit.
*
* Exported because "is there a ceiling at all" and "is the ceiling above one"
* are two different questions, and callers kept reaching for `isTrackable` to
* ask the first — see the note on it.
*/
function watchableSeasons(entry: TrackableEntry): number | null {
export function seasonCeiling(entry: TrackableEntry): number | null {
if (entry.mediaType !== 'tv') return null;
return entry.airedSeasons ?? entry.totalSeasons;
}

Expand All @@ -76,8 +82,8 @@ function watchableSeasons(entry: TrackableEntry): number | null {
* announced, which is the case that used to render a misleading "0 of 2".
*/
export function isTrackable(entry: TrackableEntry): boolean {
const watchable = watchableSeasons(entry);
return entry.mediaType === 'tv' && watchable !== null && watchable > 1;
const watchable = seasonCeiling(entry);
return watchable !== null && watchable > 1;
}

export function getSeasonProgress(entry: TrackableEntry): SeasonProgress {
Expand All @@ -93,7 +99,7 @@ export function getSeasonProgress(entry: TrackableEntry): SeasonProgress {
};
}

const airedSeasons = watchableSeasons(entry) as number;
const airedSeasons = seasonCeiling(entry) as number;
const totalSeasons = Math.max(entry.totalSeasons ?? airedSeasons, airedSeasons);
const seasonsSeen = clampSeasons(entry.seasonsSeen, airedSeasons);
const moreComing = totalSeasons > airedSeasons;
Expand Down
73 changes: 73 additions & 0 deletions src/lib/domain/watchlist.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,79 @@ describe('applyWatchlistView — filtering', () => {
expect(countByStatus(partWay, now).inProgress).toBe(1);
});

/**
* A brand-new show has exactly one aired season, which is below the bar the
* season *stepper* sets for itself — and "in progress" used to borrow that
* bar. The episode picker happily recorded three episodes of it; the Watching
* tab then reported nothing was being watched.
*/
it('counts a part-watched single-season show as in progress', () => {
const newShow = [
entry({
title: 'Lanterns',
mediaType: 'tv',
seasonsSeen: 0,
episodesIntoSeason: 3,
totalSeasons: 1,
airedSeasons: 1
})
];

const result = applyWatchlistView(newShow, { ...baseView, status: 'inProgress' }, now);
expect(result.map((i) => i.title)).toEqual(['Lanterns']);
expect(countByStatus(newShow, now).inProgress).toBe(1);
});

/**
* The season after the only aired one is still announced, so the ceiling is
* one and the same rule applies — this is the case the stepper's own bar was
* written for, which is exactly why "in progress" must not share it.
*/
it('counts a first season in progress while a second is only announced', () => {
const newShow = [
entry({
title: 'Neagley',
mediaType: 'tv',
seasonsSeen: 0,
episodesIntoSeason: 2,
totalSeasons: 2,
airedSeasons: 1,
nextSeasonNumber: 2,
nextSeasonAirDate: '2027-01-15'
})
];

expect(countByStatus(newShow, now).inProgress).toBe(1);
});

/** Nothing watched is nothing watched, however many seasons have aired. */
it('leaves an untouched single-season show out of in progress', () => {
const untouched = [
entry({
title: 'Unstarted',
mediaType: 'tv',
seasonsSeen: 0,
episodesIntoSeason: 0,
totalSeasons: 1,
airedSeasons: 1
})
];

expect(countByStatus(untouched, now).inProgress).toBe(0);
});

/**
* A show whose season data has not resolved yet has no position to report, so
* it cannot be in the middle of anything.
*/
it('leaves a show with unresolved season data out of in progress', () => {
const unresolved = [
entry({ title: 'Unknown', mediaType: 'tv', episodesIntoSeason: 3, airedSeasons: null })
];

expect(countByStatus(unresolved, now).inProgress).toBe(0);
});

it('filters by "upcoming" status', () => {
const result = applyWatchlistView(withUpcoming, { ...baseView, status: 'upcoming' }, now);
expect(result.map((i) => i.title)).toEqual(['Dune: Part Three', 'Untitled Sequel']);
Expand Down
13 changes: 11 additions & 2 deletions src/lib/domain/watchlist.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isTrackable } from './progress';
import { seasonCeiling } from './progress';
import { hasStarted } from './episodes';
import { hasUpcoming, isInUpcomingWindow, upcomingSortKey, type UpcomingFilter } from './upcoming';
import type { MediaType } from '../types';
Expand Down Expand Up @@ -133,9 +133,18 @@ export function applyWatchlistView<T extends WatchlistEntry>(
* "Started" is the episode bookmark as well as the season counter, or a show you
* are three episodes into would answer the question correctly on its own card
* and still be missing from the rail that asks it.
*
* The bar is one aired season, not the two that `isTrackable` asks for. That
* predicate decides whether the season *stepper* earns its place, and answers
* no below two seasons because a 0-to-1 counter is pure friction — a question
* with nothing to do with this one. Borrowing it meant a brand-new show could
* never be in the middle of anything, however many of its episodes had been
* ticked off: the episode picker recorded them, the card read "Next: S1E4",
* and the Watching tab still said nothing was being watched.
*/
export function isInProgress(item: WatchlistEntry): boolean {
return isTrackable(item) && hasStarted(item) && !item.watched;
const ceiling = seasonCeiling(item);
return ceiling !== null && ceiling >= 1 && hasStarted(item) && !item.watched;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/routes/watchlist/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
items={continueWatching}
onSelect={(item) => (selected = item)}
onSetSeasons={seasonProgressToast}
onToggle={() => withToast('Marked as watched')}
/>

<div class="mb-4">
Expand Down
Loading