diff --git a/src/lib/components/media/ComingSoon.svelte b/src/lib/components/media/ComingSoon.svelte deleted file mode 100644 index c300a49..0000000 --- a/src/lib/components/media/ComingSoon.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
- {#each groups as group, groupIndex (group.window)} -
-
- -

- {group.label} -

- {group.items.length} -
- - - {#each group.items as { item, upcoming }, index (item.id)} -
- onSelect(item)} - onToggle={onToggle(item)} - onSetSeasons={onSetSeasons(item)} - onRemove={onRemove(item)} - /> - -

- {#if upcoming.kind === 'season'} - Season {upcoming.seasonNumber} - {:else} - - {releaseVerb(item.mediaType)} - {/if} - · {upcoming.date ? upcoming.fullDate : 'date not announced'} -

-
- {/each} -
-
- {/each} -
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');
@@ -81,25 +116,33 @@ ]} /> - + {#if showingWindows} + + {:else} + + {/if}
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')} + /> +
+ {/each} +
{/if} {/if}