From 2ce8fda035b15a1e999e21c6db58138ea5342ea6 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sat, 19 Sep 2026 01:23:40 -0600 Subject: [PATCH] fix(watchlist): count a part-watched single season as Watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new show has exactly one aired season, and "in progress" was gated on `isTrackable` — the predicate that decides whether the season *stepper* earns its place, which answers no below two seasons because a 0-to-1 counter is pure friction. Two unrelated questions, one bar. So the episode picker recorded three episodes of Lanterns, the card read "Next: S1E4", and the Watching tab still said nothing was being watched. Every surface that asks "what am I in the middle of" was really asking "does this show deserve a stepper". `isInProgress` now measures against the season ceiling itself: one aired season is enough to be part-way through something. `seasonCeiling` is lifted out of `progress` to say that, keeping the fallback to the total for rows saved before air dates were tracked. The rail had quietly inherited the same assumption — it only ever drew the stepper, because only multi-season shows reached it. A single-season show has none to draw, and drawing one anyway printed "0/0" beside an empty bar, so those get the card's own watched toggle instead. Closes #88 --- .../components/media/ContinueWatching.svelte | 40 ++++++-- .../media/ContinueWatching.svelte.spec.ts | 94 +++++++++++++++++++ src/lib/domain/progress.ts | 16 +++- src/lib/domain/watchlist.spec.ts | 73 ++++++++++++++ src/lib/domain/watchlist.ts | 13 ++- src/routes/watchlist/+page.svelte | 1 + 6 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 src/lib/components/media/ContinueWatching.svelte.spec.ts 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')} />