Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 0 additions & 100 deletions src/lib/components/media/ComingSoon.svelte

This file was deleted.

85 changes: 64 additions & 21 deletions src/lib/components/media/WatchlistToolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,14 +22,20 @@
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<typeof countByUpcomingWindow>;
}

let {
counts,
status = $bindable(),
type = $bindable(),
sort = $bindable(),
query = $bindable()
query = $bindable(),
window = $bindable(),
windowCounts
}: Props = $props();

/**
Expand All @@ -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');
</script>

<div class="space-y-2.5">
Expand Down Expand Up @@ -81,25 +116,33 @@
]}
/>

<label class="relative">
<span class="sr-only">Sort watchlist</span>
<select
bind:value={sort}
class="cursor-pointer appearance-none rounded-xl bg-surface py-2 pr-8 pl-3 text-xs font-semibold text-ink-muted ring-1 ring-line transition-colors duration-200 hover:text-ink sm:text-sm"
>
<option value="recent">Recently added</option>
<option value="rating">Top rated</option>
<option value="title">A–Z</option>
{#if counts.upcoming > 0}
<option value="soonest">Releasing soonest</option>
{/if}
</select>
<span
class="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 rotate-90 text-ink-faint"
aria-hidden="true"
>
<Icon name="chevronRight" size={13} />
</span>
</label>
{#if showingWindows}
<SegmentedControl
bind:value={window}
label="Filter by release window"
options={windowOptions}
/>
{:else}
<label class="relative">
<span class="sr-only">Sort watchlist</span>
<select
bind:value={sort}
class="cursor-pointer appearance-none rounded-xl bg-surface py-2 pr-8 pl-3 text-xs font-semibold text-ink-muted ring-1 ring-line transition-colors duration-200 hover:text-ink sm:text-sm"
>
<option value="recent">Recently added</option>
<option value="rating">Top rated</option>
<option value="title">A–Z</option>
{#if counts.upcoming > 0}
<option value="soonest">Releasing soonest</option>
{/if}
</select>
<span
class="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 rotate-90 text-ink-faint"
aria-hidden="true"
>
<Icon name="chevronRight" size={13} />
</span>
</label>
{/if}
</div>
</div>
101 changes: 74 additions & 27 deletions src/lib/domain/upcoming.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): UpcomingEntry => ({
mediaType: 'tv',
releaseDate: '2010-01-01',
Expand Down Expand Up @@ -105,43 +111,84 @@ describe('upcomingSortKey', () => {
});
});

describe('groupByUpcomingWindow', () => {
const named = (title: string, over: Partial<UpcomingEntry>) => ({ 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
});
});
});
Loading