diff --git a/src/lib/components/media/ContinueWatching.svelte b/src/lib/components/media/ContinueWatching.svelte
index ba6e9a2..2ad7504 100644
--- a/src/lib/components/media/ContinueWatching.svelte
+++ b/src/lib/components/media/ContinueWatching.svelte
@@ -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';
@@ -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();
{#if items.length > 0}
@@ -80,13 +83,34 @@
{/if}
-
+
+ {#if progress.trackable}
+
+ {:else}
+
+ {/if}
{/each}
diff --git a/src/lib/components/media/ContinueWatching.svelte.spec.ts b/src/lib/components/media/ContinueWatching.svelte.spec.ts
new file mode 100644
index 0000000..e1cb594
--- /dev/null
+++ b/src/lib/components/media/ContinueWatching.svelte.spec.ts
@@ -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 {
+ 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 = {}) {
+ 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');
+ });
+});
diff --git a/src/lib/domain/progress.ts b/src/lib/domain/progress.ts
index 4a04203..ae929dc 100644
--- a/src/lib/domain/progress.ts
+++ b/src/lib/domain/progress.ts
@@ -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;
}
@@ -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 {
@@ -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;
diff --git a/src/lib/domain/watchlist.spec.ts b/src/lib/domain/watchlist.spec.ts
index cf658dd..04535f8 100644
--- a/src/lib/domain/watchlist.spec.ts
+++ b/src/lib/domain/watchlist.spec.ts
@@ -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']);
diff --git a/src/lib/domain/watchlist.ts b/src/lib/domain/watchlist.ts
index 2645053..b44e637 100644
--- a/src/lib/domain/watchlist.ts
+++ b/src/lib/domain/watchlist.ts
@@ -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';
@@ -133,9 +133,18 @@ export function applyWatchlistView(
* "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;
}
/**
diff --git a/src/routes/watchlist/+page.svelte b/src/routes/watchlist/+page.svelte
index f5092ba..fa47f26 100644
--- a/src/routes/watchlist/+page.svelte
+++ b/src/routes/watchlist/+page.svelte
@@ -251,6 +251,7 @@
items={continueWatching}
onSelect={(item) => (selected = item)}
onSetSeasons={seasonProgressToast}
+ onToggle={() => withToast('Marked as watched')}
/>