diff --git a/src/lib/components/media/WatchlistToolbar.svelte b/src/lib/components/media/WatchlistToolbar.svelte
index 1b51e47..f3cbba2 100644
--- a/src/lib/components/media/WatchlistToolbar.svelte
+++ b/src/lib/components/media/WatchlistToolbar.svelte
@@ -2,6 +2,11 @@
import Icon from '$lib/components/ui/Icon.svelte';
import SegmentedControl from '$lib/components/ui/SegmentedControl.svelte';
import type { countByStatus } from '$lib/domain/watchlist';
+ import {
+ UPCOMING_FILTERS,
+ UPCOMING_FILTER_LABELS,
+ type countByUpcomingWindow
+ } from '$lib/domain/upcoming';
/**
* Filters for the saved list.
@@ -17,6 +22,10 @@
type: string;
sort: string;
query: string;
+ /** Which release window the Upcoming tab is narrowed to. */
+ window: string;
+ /** How many pending titles sit in each window, for the chip numbers. */
+ windowCounts: ReturnType;
}
let {
@@ -24,7 +33,9 @@
status = $bindable(),
type = $bindable(),
sort = $bindable(),
- query = $bindable()
+ query = $bindable(),
+ window = $bindable(),
+ windowCounts
}: Props = $props();
/**
@@ -48,6 +59,30 @@
: []),
{ value: 'watched', label: 'Watched', count: counts.watched }
]);
+
+ /**
+ * The release windows, offered as a filter rather than as headings.
+ *
+ * They used to split the view into a grid per window, which is what made a
+ * handful of titles render as several rows holding one card each. As chips
+ * they narrow a single grid, and an empty window is dropped for the same
+ * reason an empty status tab is: a chip that leads to nothing is noise.
+ */
+ const windowOptions = $derived(
+ UPCOMING_FILTERS.filter((value) => value === 'all' || windowCounts[value] > 0).map((value) => ({
+ value,
+ label: UPCOMING_FILTER_LABELS[value],
+ count: windowCounts[value]
+ }))
+ );
+
+ /**
+ * The Upcoming tab takes the place of the sort control rather than sitting
+ * beside it. That tab is about *when*, so it is always ordered soonest-first
+ * — the grouped view it replaces offered no ordering choice either — and
+ * "Recently added" over a list of release dates answers nobody's question.
+ */
+ const showingWindows = $derived(status === 'upcoming');
diff --git a/src/lib/domain/upcoming.spec.ts b/src/lib/domain/upcoming.spec.ts
index b697621..d211003 100644
--- a/src/lib/domain/upcoming.spec.ts
+++ b/src/lib/domain/upcoming.spec.ts
@@ -1,15 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
+ countByUpcomingWindow,
getUpcomingInfo,
- groupByUpcomingWindow,
hasUpcoming,
+ isInUpcomingWindow,
upcomingSortKey,
+ upcomingWindowFor,
type UpcomingEntry
} from './upcoming';
/** Fixed "today" so none of these drift with the calendar. */
const now = new Date('2026-08-12T12:00:00Z');
+/** A date `days` from the fixed "today", as TMDB writes them. */
+const isoIn = (days: number) =>
+ new Date(now.getTime() + days * 86_400_000).toISOString().slice(0, 10);
+
const entry = (over: Partial = {}): UpcomingEntry => ({
mediaType: 'tv',
releaseDate: '2010-01-01',
@@ -105,43 +111,84 @@ describe('upcomingSortKey', () => {
});
});
-describe('groupByUpcomingWindow', () => {
- const named = (title: string, over: Partial) => ({ title, ...entry(over) });
+describe('upcomingWindowFor', () => {
+ const inDays = (n: number) => ({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(n) });
- it('buckets by how soon, and drops empty groups', () => {
- const groups = groupByUpcomingWindow(
- [
- named('next week', { nextSeasonNumber: 2, nextSeasonAirDate: '2026-08-16' }),
- named('next month', { nextSeasonNumber: 2, nextSeasonAirDate: '2026-09-05' }),
- named('far off', { nextSeasonNumber: 2, nextSeasonAirDate: '2027-06-01' }),
- named('no date', { nextSeasonNumber: 2, nextSeasonAirDate: null }),
- named('already out', {})
- ],
- now
+ it('places a title in the window its date falls in', () => {
+ expect(upcomingWindowFor(entry(inDays(1)), now)).toBe('thisWeek');
+ expect(upcomingWindowFor(entry(inDays(7)), now)).toBe('thisWeek');
+ expect(upcomingWindowFor(entry(inDays(8)), now)).toBe('thisMonth');
+ expect(upcomingWindowFor(entry(inDays(31)), now)).toBe('thisMonth');
+ expect(upcomingWindowFor(entry(inDays(32)), now)).toBe('later');
+ });
+
+ it('gives an announced title with no date its own window', () => {
+ expect(upcomingWindowFor(entry({ nextSeasonNumber: 2, nextSeasonAirDate: null }), now)).toBe(
+ 'undated'
);
+ });
- expect(groups.map((g) => g.window)).toEqual(['thisWeek', 'thisMonth', 'later', 'undated']);
- expect(groups.flatMap((g) => g.items.map((i) => i.item.title))).toEqual([
- 'next week',
- 'next month',
- 'far off',
- 'no date'
- ]);
+ // Nothing pending is not a window; it is an absence, and the caller filters
+ // on it before ever asking which bucket something is in.
+ it('answers null for a title with nothing pending', () => {
+ expect(upcomingWindowFor(entry(), now)).toBeNull();
});
+});
- it('sorts within a bucket by how soon', () => {
- const groups = groupByUpcomingWindow(
+describe('isInUpcomingWindow', () => {
+ const soon = entry({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(3) });
+
+ // 'all' is what the filter lands on, so it must not quietly drop anything.
+ it('lets everything pending through on "all"', () => {
+ expect(isInUpcomingWindow(soon, 'all', now)).toBe(true);
+ expect(
+ isInUpcomingWindow(entry({ nextSeasonNumber: 2, nextSeasonAirDate: null }), 'all', now)
+ ).toBe(true);
+ });
+
+ it('matches only its own window otherwise', () => {
+ expect(isInUpcomingWindow(soon, 'thisWeek', now)).toBe(true);
+ expect(isInUpcomingWindow(soon, 'thisMonth', now)).toBe(false);
+ expect(isInUpcomingWindow(soon, 'later', now)).toBe(false);
+ expect(isInUpcomingWindow(soon, 'undated', now)).toBe(false);
+ });
+
+ it('excludes a title with nothing pending from every window', () => {
+ for (const window of ['all', 'thisWeek', 'thisMonth', 'later', 'undated'] as const) {
+ expect(isInUpcomingWindow(entry(), window, now)).toBe(false);
+ }
+ });
+});
+
+describe('countByUpcomingWindow', () => {
+ /**
+ * The counts the filter chips wear. A chip that shows a number and then an
+ * empty grid is worse than no chip, so these come from the same predicate the
+ * filter itself uses.
+ */
+ it('counts each window, and everything pending under "all"', () => {
+ const counts = countByUpcomingWindow(
[
- named('later that month', { nextSeasonNumber: 2, nextSeasonAirDate: '2026-09-05' }),
- named('sooner', { nextSeasonNumber: 2, nextSeasonAirDate: '2026-08-25' })
+ entry({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(2) }),
+ entry({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(5) }),
+ entry({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(20) }),
+ entry({ nextSeasonNumber: 2, nextSeasonAirDate: isoIn(300) }),
+ entry({ nextSeasonNumber: 2, nextSeasonAirDate: null }),
+ entry()
],
now
);
- expect(groups[0].items.map((i) => i.item.title)).toEqual(['sooner', 'later that month']);
+ expect(counts).toEqual({ all: 5, thisWeek: 2, thisMonth: 1, later: 1, undated: 1 });
});
- it('returns nothing when there is nothing pending', () => {
- expect(groupByUpcomingWindow([entry()], now)).toEqual([]);
+ it('counts nothing for a list with nothing pending', () => {
+ expect(countByUpcomingWindow([entry()], now)).toEqual({
+ all: 0,
+ thisWeek: 0,
+ thisMonth: 0,
+ later: 0,
+ undated: 0
+ });
});
});
diff --git a/src/lib/domain/upcoming.ts b/src/lib/domain/upcoming.ts
index e087c81..323f739 100644
--- a/src/lib/domain/upcoming.ts
+++ b/src/lib/domain/upcoming.ts
@@ -87,24 +87,44 @@ export function upcomingSortKey(entry: UpcomingEntry, now: Date = new Date()): n
export type UpcomingWindow = 'thisWeek' | 'thisMonth' | 'later' | 'undated';
-export interface UpcomingGroup {
- window: UpcomingWindow;
- label: string;
- items: Array<{ item: T; upcoming: UpcomingInfo }>;
-}
-
-/** Bucket boundaries in days. A "month" is 31 so a 30-day month never spills. */
-const THIS_WEEK_DAYS = 7;
-const THIS_MONTH_DAYS = 31;
-
-const WINDOW_LABELS: Record = {
+/** The windows plus the unfiltered view, in the order the filter offers them. */
+export type UpcomingFilter = 'all' | UpcomingWindow;
+
+export const UPCOMING_FILTERS: readonly UpcomingFilter[] = [
+ 'all',
+ 'thisWeek',
+ 'thisMonth',
+ 'later',
+ 'undated'
+];
+
+export const UPCOMING_FILTER_LABELS: Record = {
+ all: 'All',
thisWeek: 'This week',
thisMonth: 'This month',
later: 'Later',
- undated: 'No date yet'
+ undated: 'No date'
};
-function windowFor(info: UpcomingInfo): UpcomingWindow {
+/** Bucket boundaries in days. A "month" is 31 so a 30-day month never spills. */
+const THIS_WEEK_DAYS = 7;
+const THIS_MONTH_DAYS = 31;
+
+/**
+ * Which window a pending title falls in, or null when nothing is pending.
+ *
+ * These used to be headings that split the view into one grid per window, which
+ * is what broke it: a handful of titles spread across four windows rendered as
+ * four rows of one card. They are a *filter* now — one grid, narrowed on demand
+ * — so this answers a question about a single title rather than partitioning a
+ * list.
+ */
+export function upcomingWindowFor(
+ entry: UpcomingEntry,
+ now: Date = new Date()
+): UpcomingWindow | null {
+ const info = getUpcomingInfo(entry, now);
+ if (!info) return null;
if (info.daysUntil === null) return 'undated';
if (info.daysUntil <= THIS_WEEK_DAYS) return 'thisWeek';
if (info.daysUntil <= THIS_MONTH_DAYS) return 'thisMonth';
@@ -112,36 +132,40 @@ function windowFor(info: UpcomingInfo): UpcomingWindow {
}
/**
- * Group pending titles into time windows, soonest first, dropping empty groups.
+ * Whether a title belongs in the chosen filter.
*
- * A flat grid sorted by date technically holds the same information, but reading
- * it means comparing every badge against today. Buckets answer the actual
- * question — "is there anything this week?" — without any arithmetic.
+ * `all` still excludes titles with nothing pending: the filter narrows the
+ * Upcoming view, it does not define it.
*/
-export function groupByUpcomingWindow(
- items: readonly T[],
+export function isInUpcomingWindow(
+ entry: UpcomingEntry,
+ filter: UpcomingFilter,
now: Date = new Date()
-): UpcomingGroup[] {
- const order: UpcomingWindow[] = ['thisWeek', 'thisMonth', 'later', 'undated'];
- const buckets = new Map['items']>(
- order.map((window) => [window, []])
- );
+): boolean {
+ const window = upcomingWindowFor(entry, now);
+ if (window === null) return false;
+ return filter === 'all' || window === filter;
+}
+
+/** How many pending titles sit in each window, for the numbers on the chips. */
+export function countByUpcomingWindow(
+ items: readonly UpcomingEntry[],
+ now: Date = new Date()
+): Record {
+ const counts: Record = {
+ all: 0,
+ thisWeek: 0,
+ thisMonth: 0,
+ later: 0,
+ undated: 0
+ };
for (const item of items) {
- const upcoming = getUpcomingInfo(item, now);
- if (!upcoming) continue;
- buckets.get(windowFor(upcoming))!.push({ item, upcoming });
+ const window = upcomingWindowFor(item, now);
+ if (window === null) continue;
+ counts.all++;
+ counts[window]++;
}
- return order
- .map((window) => ({
- window,
- label: WINDOW_LABELS[window],
- items: (buckets.get(window) ?? []).sort(
- (a, b) =>
- (a.upcoming.daysUntil ?? Number.POSITIVE_INFINITY) -
- (b.upcoming.daysUntil ?? Number.POSITIVE_INFINITY)
- )
- }))
- .filter((group) => group.items.length > 0);
+ return counts;
}
diff --git a/src/lib/domain/watchlist.spec.ts b/src/lib/domain/watchlist.spec.ts
index a1eee27..cf658dd 100644
--- a/src/lib/domain/watchlist.spec.ts
+++ b/src/lib/domain/watchlist.spec.ts
@@ -176,3 +176,72 @@ describe('countByStatus', () => {
});
});
});
+
+describe('applyWatchlistView — the upcoming window filter', () => {
+ /** Titles landing in each window, measured from the fixed "today". */
+ const windowed: WatchlistEntry[] = [
+ ...items,
+ entry({ title: 'Opens This Week', releaseDate: '2026-08-02' }),
+ entry({ title: 'Opens This Month', releaseDate: '2026-08-20' }),
+ entry({ title: 'Opens Much Later', releaseDate: '2027-06-01' }),
+ entry({ title: 'No Date Announced', releaseDate: null })
+ ];
+
+ const upcoming = (window: string) =>
+ applyWatchlistView(windowed, { ...baseView, status: 'upcoming', window }, now).map(
+ (item) => item.title
+ );
+
+ /**
+ * The default, and the reason the windows became a filter: one grid holding
+ * everything pending, rather than a grid per window.
+ */
+ it('shows everything pending on "all"', () => {
+ expect(upcoming('all').sort()).toEqual([
+ 'No Date Announced',
+ 'Opens Much Later',
+ 'Opens This Month',
+ 'Opens This Week'
+ ]);
+ });
+
+ it('narrows to one window on demand', () => {
+ expect(upcoming('thisWeek')).toEqual(['Opens This Week']);
+ expect(upcoming('thisMonth')).toEqual(['Opens This Month']);
+ expect(upcoming('later')).toEqual(['Opens Much Later']);
+ expect(upcoming('undated')).toEqual(['No Date Announced']);
+ });
+
+ /**
+ * The window is a lens on the Upcoming tab and nothing else. Left applied
+ * while the viewer moves to another tab, it would silently empty a list that
+ * has nothing to do with release dates.
+ */
+ it('is ignored on every other tab', () => {
+ const watched = applyWatchlistView(
+ windowed,
+ { ...baseView, status: 'watched', window: 'thisWeek' },
+ now
+ );
+ expect(watched.map((item) => item.title).sort()).toEqual(['Arrival', 'Breaking Bad']);
+ });
+
+ it('composes with the other filters rather than replacing them', () => {
+ const withSeries = [
+ ...windowed,
+ entry({ title: 'A Series This Week', mediaType: 'tv', releaseDate: '2026-08-03' })
+ ];
+ const result = applyWatchlistView(
+ withSeries,
+ { ...baseView, status: 'upcoming', window: 'thisWeek', type: 'tv' },
+ now
+ );
+ expect(result.map((item) => item.title)).toEqual(['A Series This Week']);
+ });
+
+ // An absent window must behave exactly as it did before the filter existed.
+ it('defaults to showing everything pending when unset', () => {
+ const result = applyWatchlistView(windowed, { ...baseView, status: 'upcoming' }, now);
+ expect(result).toHaveLength(4);
+ });
+});
diff --git a/src/lib/domain/watchlist.ts b/src/lib/domain/watchlist.ts
index 536f750..e52e0a9 100644
--- a/src/lib/domain/watchlist.ts
+++ b/src/lib/domain/watchlist.ts
@@ -1,6 +1,6 @@
import { isTrackable } from './progress';
import { hasStarted } from './episodes';
-import { hasUpcoming, upcomingSortKey } from './upcoming';
+import { hasUpcoming, isInUpcomingWindow, upcomingSortKey, type UpcomingFilter } from './upcoming';
import type { MediaType } from '../types';
/**
@@ -31,6 +31,15 @@ export interface WatchlistView {
status: string;
/** 'all' | 'movie' | 'tv' */
type: string;
+ /**
+ * Which release window the Upcoming tab is narrowed to, if any.
+ *
+ * Only read on that tab. These were headings that split the view into a grid
+ * per window, which is what made a handful of titles render as several rows
+ * of one card; as a filter they narrow one grid instead. Absent means the
+ * whole of what is pending, which is the landing state.
+ */
+ window?: UpcomingFilter | string;
/** 'recent' | 'rating' | 'title' | 'soonest' */
sort: string;
/** Free-text title filter. */
@@ -57,11 +66,24 @@ export function applyWatchlistView(
view.status === 'all' ||
(view.status === 'toWatch' && !item.watched) ||
(view.status === 'watched' && item.watched) ||
- (view.status === 'upcoming' && hasUpcoming(item, now)) ||
+ (view.status === 'upcoming' && matchesUpcomingWindow(item)) ||
(view.status === 'inProgress' && isInProgress(item));
return matchesStatus && matchesRest(item);
});
+ /**
+ * Pending, and in the chosen window.
+ *
+ * The window narrows this tab and belongs to it alone — every other status
+ * ignores it, so a window left applied cannot silently empty a list that has
+ * nothing to do with release dates.
+ */
+ function matchesUpcomingWindow(item: WatchlistEntry): boolean {
+ const window = view.window;
+ if (!window || window === 'all') return hasUpcoming(item, now);
+ return isInUpcomingWindow(item, window as UpcomingFilter, now);
+ }
+
function matchesRest(item: WatchlistEntry): boolean {
const matchesType = view.type === 'all' || item.mediaType === view.type;
const matchesQuery = query === '' || item.title.toLowerCase().includes(query);
diff --git a/src/routes/watchlist/+page.svelte b/src/routes/watchlist/+page.svelte
index bb301af..f5092ba 100644
--- a/src/routes/watchlist/+page.svelte
+++ b/src/routes/watchlist/+page.svelte
@@ -11,7 +11,6 @@
import WatchlistCard from '$lib/components/media/WatchlistCard.svelte';
import WatchlistToolbar from '$lib/components/media/WatchlistToolbar.svelte';
import ContinueWatching from '$lib/components/media/ContinueWatching.svelte';
- import ComingSoon from '$lib/components/media/ComingSoon.svelte';
import AutoDeleteControl from '$lib/components/media/AutoDeleteControl.svelte';
import CalendarFeed from '$lib/components/media/CalendarFeed.svelte';
import ShareList from '$lib/components/media/ShareList.svelte';
@@ -21,6 +20,7 @@
import { absorb, withToast } from '$lib/forms/feedback';
import { pendingSaves } from '$lib/stores/pending-saves.svelte';
import { applyWatchlistView, countByStatus, isInProgress } from '$lib/domain/watchlist';
+ import { countByUpcomingWindow } from '$lib/domain/upcoming';
import { mediaKey } from '$lib/domain/media';
import type { WatchlistItem } from '$lib/server/db/schema';
import type { MediaType, SavedEntry } from '$lib/types';
@@ -51,6 +51,8 @@
let typeFilter = $state('all');
let sortBy = $state('recent');
let listQuery = $state('');
+ /** Which release window Upcoming is narrowed to; ignored on every other tab. */
+ let upcomingWindow = $state('all');
let selected = $state<{ tmdbId: number; mediaType: MediaType } | null>(null);
@@ -79,15 +81,41 @@
: []
);
+ /**
+ * Upcoming is always soonest-first. It is the tab about *when*, and the
+ * grouped view it replaces imposed that order too, so nothing is taken away.
+ */
+ const effectiveSort = $derived(statusTab === 'upcoming' ? 'soonest' : sortBy);
+
const visibleItems = $derived(
applyWatchlistView(items, {
status: statusTab,
type: typeFilter,
- sort: sortBy,
- query: listQuery
+ sort: effectiveSort,
+ query: listQuery,
+ window: upcomingWindow
})
);
+ /**
+ * The numbers on the window chips.
+ *
+ * Counted from the same set the grid draws from, minus the window itself —
+ * so a chip reading "3" can never lead to an empty grid once the type or the
+ * search box has already narrowed things.
+ */
+ const upcomingWindowCounts = $derived(
+ countByUpcomingWindow(
+ applyWatchlistView(items, {
+ status: 'upcoming',
+ type: typeFilter,
+ sort: 'recent',
+ query: listQuery,
+ window: 'all'
+ })
+ )
+ );
+
// ":" -> saved row, so the modal renders the same controls
// as the card without issuing a second query. Pending changes are laid over
// the top, which is what lets the sheet answer a tap before the server does —
@@ -122,6 +150,9 @@
statusTab = 'toWatch';
typeFilter = 'all';
listQuery = '';
+ // Cleared with the rest, or coming back to Upcoming would land on a
+ // narrowing the viewer had already asked to be rid of.
+ upcomingWindow = 'all';
}
/**
@@ -229,6 +260,8 @@
bind:type={typeFilter}
bind:sort={sortBy}
bind:query={listQuery}
+ bind:window={upcomingWindow}
+ windowCounts={upcomingWindowCounts}
/>
@@ -277,40 +310,39 @@
about when things happen, which is the only place subscribing a
calendar to the list means anything. -->
-
-
- (selected = item)}
- onToggle={(item) =>
- withToast(item.watched ? 'Moved back to your list' : 'Marked as watched')}
- onSetSeasons={seasonProgressToast}
- onRemove={(item) => withToast(`Removed “${item.title}”`, 'info')}
- />
- {:else}
-
- {#each visibleItems as item, index (item.id)}
-
- (selected = item)}
- onToggle={withToast(item.watched ? 'Moved back to your list' : 'Marked as watched')}
- onSetSeasons={seasonProgressToast(item)}
- onRemove={withToast(`Removed “${item.title}”`, 'info')}
- onKeep={withToast(`Keeping “${item.title}” on your list`, 'info')}
- />
-
- {/each}
-
{/if}
+
+
+
+ {#each visibleItems as item, index (item.id)}
+
+ (selected = item)}
+ onToggle={withToast(item.watched ? 'Moved back to your list' : 'Marked as watched')}
+ onSetSeasons={seasonProgressToast(item)}
+ onRemove={withToast(`Removed “${item.title}”`, 'info')}
+ onKeep={withToast(`Keeping “${item.title}” on your list`, 'info')}
+ />
+