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
10 changes: 9 additions & 1 deletion src/lib/components/media/ComingSoon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import PosterGrid from './PosterGrid.svelte';
import WatchlistCard from './WatchlistCard.svelte';
import { groupByUpcomingWindow } from '$lib/domain/upcoming';
import { releaseVerb } from '$lib/domain/release';
import type { WatchlistItem } from '$lib/server/db/schema';

/**
Expand Down Expand Up @@ -80,7 +81,14 @@
{#if upcoming.kind === 'season'}
Season {upcoming.seasonNumber}
{:else}
Premieres
<!--
A film opens and a series premieres, and this line used to
call both of them a premiere. It matters more now that the
card directly above names the same event in its own words:
"Out Sep 9" over "Premieres Β· Wednesday, September 9" is the
card disagreeing with its own caption.
-->
{releaseVerb(item.mediaType)}
{/if}
Β· {upcoming.date ? upcoming.fullDate : 'date not announced'}
</p>
Expand Down
83 changes: 66 additions & 17 deletions src/lib/components/media/WatchlistCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import SeasonTracker from './SeasonTracker.svelte';
import { getSeasonProgress } from '$lib/domain/progress';
import { progressNote } from '$lib/domain/episodes';
import { getReleaseInfo } from '$lib/domain/release';
import { canMarkWatched, getReleaseInfo, pendingReleaseLabel } from '$lib/domain/release';
import {
daysUntilDeletion,
shouldWarnAboutDeletion,
Expand Down Expand Up @@ -58,8 +58,27 @@

const progress = $derived(getSeasonProgress(item));

// "Mark as watched" still works on an unreleased title (premieres exist), but
// it shouldn't be the loudest thing on a card for something that isn't out.
/**
* What replaces the "Watched" button on a title that is not out yet, or null
* when there is nothing to wait for.
*
* The button used to sit here, quieter but still pressable, which meant a film
* opening next spring could be filed as seen. Naming the date instead is not
* only a refusal β€” it is the answer to the question the button was standing in
* front of. `canMarkWatched` holds the rule and the reason it stops at
* confirmed dates.
*
* Only ever shown for a title that is *not* already marked watched. A row that
* predates this rule keeps its way back out; the rule governs the way in.
*/
const pendingRelease = $derived(
!item.watched && !canMarkWatched(item.releaseDate)
? pendingReleaseLabel(item.mediaType, item.releaseDate)
: null
);

// A title with no date at all is still tickable β€” see `canMarkWatched` β€” but
// it should not wear the loudest button on the card either.
const unreleased = $derived(getReleaseInfo(item.releaseDate).state !== 'released');

/**
Expand Down Expand Up @@ -120,21 +139,51 @@
</div>
{:else}
<div class="flex items-stretch gap-1.5">
<form method="POST" action="?/toggleWatched" use:enhance={onToggle} class="min-w-0 flex-1">
<input type="hidden" name="id" value={item.id} />
<button
type="submit"
class="flex min-h-11 w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl px-2 text-xs font-semibold transition-colors duration-200 active:scale-[0.98]
{item.watched
? 'bg-surface-hi text-ink-muted ring-1 ring-line ring-inset hover:bg-line hover:text-ink'
: unreleased
? 'bg-surface-hi text-ink-muted ring-1 ring-line ring-inset hover:bg-line hover:text-ink'
: 'bg-mint/15 text-mint hover:bg-mint/25'}"
{#if pendingRelease}
<!--
Not a disabled button. A greyed control still reads as an action the
viewer is failing to perform, and invites the tap that does nothing;
this reads as what it is β€” a fact about the title, in the same amber
the poster badge already uses for "not out yet". The remove button
beside it stays, because changing your mind about waiting for
something is not the same as claiming to have seen it.
-->
<p
class="flex min-h-11 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-xl bg-amber/10 px-1.5 py-1 text-center text-[11px] leading-tight font-semibold text-amber"
>
<Icon name={item.watched ? 'rotate' : 'check'} size={14} stroke={2.5} />
<span class="truncate">{item.watched ? 'Unwatch' : 'Watched'}</span>
</button>
</form>
<Icon name="clock" size={14} class="shrink-0" />
<!--
Wraps rather than truncates. A far-off date carries its year β€”
"Premieres Sep 30, 2028" β€” and that does not fit a poster tile on
a five-column grid on one line; truncating it would cut the year,
which is the part that made the date worth printing. The slot is
already 44px tall for the button it replaced, so a second line
costs nothing and the row of cards stays level.
-->
<span>{pendingRelease}</span>
</p>
{:else}
<form
method="POST"
action="?/toggleWatched"
use:enhance={onToggle}
class="min-w-0 flex-1"
>
<input type="hidden" name="id" value={item.id} />
<button
type="submit"
class="flex min-h-11 w-full cursor-pointer items-center justify-center gap-1.5 rounded-xl px-2 text-xs font-semibold transition-colors duration-200 active:scale-[0.98]
{item.watched
? 'bg-surface-hi text-ink-muted ring-1 ring-line ring-inset hover:bg-line hover:text-ink'
: unreleased
? 'bg-surface-hi text-ink-muted ring-1 ring-line ring-inset hover:bg-line hover:text-ink'
: 'bg-mint/15 text-mint hover:bg-mint/25'}"
>
<Icon name={item.watched ? 'rotate' : 'check'} size={14} stroke={2.5} />
<span class="truncate">{item.watched ? 'Unwatch' : 'Watched'}</span>
</button>
</form>
{/if}

<form method="POST" action="?/remove" use:enhance={onRemove}>
<input type="hidden" name="id" value={item.id} />
Expand Down
135 changes: 135 additions & 0 deletions src/lib/components/media/WatchlistCard.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import WatchlistCard from './WatchlistCard.svelte';
import type { WatchlistItem } from '$lib/server/db/schema';

/**
* Which control a saved title gets, and whether what replaces it fits.
*
* The rule itself is covered in `domain/release`, where it belongs. What only a
* layout engine can answer is the second half: the release line stands in the
* width a button used to occupy, and a far-off date carries its year β€”
* "Premieres Sep 30, 2028" does not fit a poster tile on one line. It is allowed
* to wrap instead of truncate, because a cut year is the part worth reading. So
* this measures, the way `MediaCard`'s own tests do.
*/

/** A poster tile at its narrowest β€” five columns on a desktop grid. */
const TILE_WIDTH = 155;

/**
* The longest label the slot can be asked to hold: the long verb, a day-precise
* date, and a year β€” which `formatDate` adds as soon as it is not this one.
*/
const LONGEST = { mediaType: 'tv' as const, releaseDate: '2099-09-30' };

function item(over: Partial<WatchlistItem> = {}): WatchlistItem {
return {
id: 'item-1',
userId: 'user-1',
tmdbId: 1,
mediaType: 'movie',
title: 'Interstellar',
posterPath: null,
releaseDate: '2014-11-05',
overview: null,
voteAverage: 8.5,
watched: false,
seasonsSeen: 0,
episodesIntoSeason: 0,
totalSeasons: null,
airedSeasons: null,
nextSeasonNumber: null,
nextSeasonAirDate: null,
watchedAt: null,
addedAt: new Date(),
...over
};
}

function card(over: Partial<WatchlistItem> = {}) {
const container = document.createElement('div');
container.style.width = `${TILE_WIDTH}px`;
document.body.appendChild(container);

render(WatchlistCard, {
props: {
item: item(over),
onSelect: vi.fn(),
onToggle: vi.fn(),
onSetSeasons: vi.fn(),
onRemove: vi.fn()
},
target: container
});

return container;
}

/** The form that would mark this title watched, if the card offers one. */
const toggleForm = (root: HTMLElement) => root.querySelector('form[action*="toggleWatched"]');

/** The release line that stands in its place, if it does not. */
const releaseLine = (root: HTMLElement) => root.querySelector('p.text-amber span') as HTMLElement;

describe('WatchlistCard β€” waiting for a release', () => {
it('offers the toggle on a title that is out', () => {
const root = card();

expect(toggleForm(root)).not.toBeNull();
expect(releaseLine(root)).toBeNull();
});

it('replaces the toggle with the release date on a title that is not', () => {
const root = card({ releaseDate: '2099-08-14' });

expect(toggleForm(root)).toBeNull();
expect(releaseLine(root).textContent?.trim()).toBe('Out Aug 14, 2099');
});

it('says what a series is doing, not what a film is', () => {
const root = card({ mediaType: 'tv', releaseDate: '2099-08-14' });

expect(releaseLine(root).textContent?.trim()).toBe('Premieres Aug 14, 2099');
});

// Not knowing when something came out is not knowing that it has not; see
// `canMarkWatched`.
it('still offers the toggle when TMDB has no date at all', () => {
const root = card({ releaseDate: null });

expect(toggleForm(root)).not.toBeNull();
expect(releaseLine(root)).toBeNull();
});

/**
* The way back out is never taken away. A row saved before this rule, or one
* whose date TMDB has since moved outwards, must not be left claiming
* something its owner cannot take back.
*/
it('keeps the toggle on a watched title even once its date is in the future', () => {
const root = card({ releaseDate: '2099-08-14', watched: true });

expect(toggleForm(root)).not.toBeNull();
expect(releaseLine(root)).toBeNull();
});

it('leaves the remove button alone', () => {
const root = card({ releaseDate: '2099-08-14' });

expect(root.querySelector('form[action*="remove"]')).not.toBeNull();
});

// The reason this file measures rather than only reading text.
it('shows the longest label it can be given in full, wrapping if it must', () => {
const root = card(LONGEST);
const line = releaseLine(root);
const pill = line.closest('p') as HTMLElement;

expect(line.textContent?.trim()).toBe('Premieres Sep 30, 2099');
// Nothing clipped horizontally, and the wrap stays inside the slot the
// button used to occupy, so a row of cards stays level.
expect(line.scrollWidth).toBeLessThanOrEqual(line.clientWidth);
expect(pill.scrollHeight).toBeLessThanOrEqual(pill.clientHeight);
});
});
75 changes: 74 additions & 1 deletion src/lib/domain/release.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { getReleaseInfo, isUpcoming, releaseVerb } from './release';
import {
canMarkWatched,
getReleaseInfo,
isUpcoming,
pendingReleaseLabel,
releaseVerb
} from './release';

// Fixed "today" so every assertion is deterministic regardless of when CI runs.
const now = new Date('2026-07-29T12:00:00Z');
Expand Down Expand Up @@ -102,3 +108,70 @@ describe('releaseVerb', () => {
expect(releaseVerb('tv')).toBe('Premieres');
});
});

describe('canMarkWatched', () => {
it('allows anything already out', () => {
expect(canMarkWatched('1999-03-31', now)).toBe(true);
// Released today counts as released; see `getReleaseInfo`.
expect(canMarkWatched('2026-07-29', now)).toBe(true);
});

it('refuses a confirmed future date', () => {
expect(canMarkWatched('2026-07-30', now)).toBe(false);
expect(canMarkWatched('2029-12-19', now)).toBe(false);
});

it('refuses a future date given only as a month or a year', () => {
expect(canMarkWatched('2027-03', now)).toBe(false);
expect(canMarkWatched('2028', now)).toBe(false);
});

/**
* The decision this rule turns on. "No date" means two opposite things β€” a
* production announced years out, and an obscure catalogue title nobody has
* dated β€” so refusing on it would lock a film somebody watched decades ago on
* the strength of a missing field.
*/
it('allows a title TMDB has no date for at all', () => {
expect(canMarkWatched(null, now)).toBe(true);
expect(canMarkWatched('', now)).toBe(true);
expect(canMarkWatched('not-a-date', now)).toBe(true);
});

it('agrees with the badge the card already shows', () => {
for (const date of ['1999-03-31', '2026-07-29', '2026-07-30', '2029-12-19', null]) {
expect(canMarkWatched(date, now)).toBe(!isUpcoming(date, now));
}
});
});

describe('pendingReleaseLabel', () => {
// "Aug 14" alone does not say whether a film opens or a series premieres.
it('names the event as well as the date', () => {
expect(pendingReleaseLabel('movie', '2026-08-14', now)).toBe('Out Aug 14');
expect(pendingReleaseLabel('tv', '2026-08-14', now)).toBe('Premieres Aug 14');
});

/**
* The date, never the countdown. `shortLabel` becomes "In 3 days" inside the
* last week, and "In theaters In 3 days" reads like a typo β€” the countdown is
* on the poster badge directly above instead.
*/
it('gives the date even when the badge above is counting down', () => {
expect(pendingReleaseLabel('movie', '2026-07-30', now)).toBe('Out Jul 30');
expect(pendingReleaseLabel('tv', '2026-08-02', now)).toBe('Premieres Aug 2');
});

// The longest thing this slot can be asked to hold; it has to fit a poster
// tile on a five-column grid without truncating.
it('says only as much as the date does', () => {
expect(pendingReleaseLabel('movie', '2027-03', now)).toBe('Out Mar 2027');
expect(pendingReleaseLabel('tv', '2028', now)).toBe('Premieres 2028');
});

// Nothing to say means nothing shown: the card falls back to its real control.
it('says nothing about a title that is out, or one with no date', () => {
expect(pendingReleaseLabel('movie', '1999-03-31', now)).toBeNull();
expect(pendingReleaseLabel('movie', null, now)).toBeNull();
});
});
Loading