From f717a402393daf718f117a9e38b5b99fab4db0bb Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Tue, 8 Sep 2026 20:34:35 -0600 Subject: [PATCH] fix(watchlist): make Upcoming one grid, with the time windows as a filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upcoming rendered a separate grid per time window, and both of its bugs came from that. A handful of pending titles spread across four windows became four rows holding one card each — with three fifths of every row empty. That is the ordinary case, not an edge case: three series in three windows is what a real list looks like. And the caption under each card overflowed its cell onto the next heading. The card fills its cell by design — `h-full` is what keeps a row of cards level — so a sibling placed after it inside the same grid item has nowhere to go. Measured at 23-39px of overlap, which is why titles and headings collided. The windows are a filter now: This week / This month / Later / No date, as chips beside the type control, with counts drawn from the same set the grid draws from so a chip reading "3" can never open onto an empty grid. An empty window is dropped, on the same reasoning as an empty status tab. The tab is always ordered soonest-first and gives up the sort dropdown to make room — it is the tab about *when*, the grouped view it replaces imposed that order too, and "Recently added" over a list of release dates answers nobody's question. With the grouping gone, Upcoming draws from the same grid as every other tab and the caption goes with it. Nothing is lost that the card does not already say: the poster badge carries how soon, the action slot carries the verb and the date, and the full weekday date was always in the detail sheet. --- src/lib/components/media/ComingSoon.svelte | 100 ----------------- .../components/media/WatchlistToolbar.svelte | 85 ++++++++++---- src/lib/domain/upcoming.spec.ts | 101 ++++++++++++----- src/lib/domain/upcoming.ts | 100 ++++++++++------- src/lib/domain/watchlist.spec.ts | 69 ++++++++++++ src/lib/domain/watchlist.ts | 26 ++++- src/routes/watchlist/+page.svelte | 104 ++++++++++++------ 7 files changed, 361 insertions(+), 224 deletions(-) delete mode 100644 src/lib/components/media/ComingSoon.svelte 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}