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
27 changes: 24 additions & 3 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,33 @@ import { defineConfig } from '@playwright/test';
* database). Because it needs those secrets, e2e is a local/manual step and is
* intentionally not part of the CI workflow.
*/

/**
* A port of this suite's own, deliberately not Vite's default.
*
* On 5173 the suite once ran end to end against somebody else's application:
* another Vite project had taken the default port first, `reuseExistingServer`
* adopted it without checking whose it was, and all seventeen tests failed
* against a stranger's HTML — including the ones about `robots.txt` and
* security headers, which pointed nowhere near the truth. The dangerous
* direction is the other one: a reused server running stale code can report
* green for a failure that is really there.
*
* A port nothing else defaults to is what removes that collision. `--strictPort`
* closes the second half of it — without it Vite answers a busy port by quietly
* moving to the next one, and the suite would then be pointed at nothing.
*/
const PORT = 5273;

export default defineConfig({
testMatch: '**/*.e2e.{ts,js}',
use: { baseURL: 'http://localhost:5173' },
use: { baseURL: `http://localhost:${PORT}` },
webServer: {
command: 'npm run dev',
port: 5173,
command: `npm run dev -- --port ${PORT} --strictPort`,
port: PORT,
// Kept for local re-runs, which are otherwise a fresh server every time.
// It is safe here in a way it was not on 5173: nothing but this suite ever
// listens on this port, so the server being reused is always our own.
reuseExistingServer: !process.env.CI
}
});
8 changes: 4 additions & 4 deletions src/lib/components/media/ContinueWatching.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { getSeasonProgress } from '$lib/domain/progress';
import { progressNote } from '$lib/domain/episodes';
import { posterUrl } from '$lib/format/tmdb-image';
import type { WatchlistItem } from '$lib/server/db/schema';
import type { SavedTitle } from '$lib/domain/watchlist';

/**
* "What was I in the middle of?" — the first question anyone opens a watchlist
Expand All @@ -19,9 +19,9 @@
* row of vertical space.
*/
interface Props {
items: WatchlistItem[];
onSelect: (item: WatchlistItem) => void;
onSetSeasons: (item: WatchlistItem) => SubmitFunction;
items: SavedTitle[];
onSelect: (item: SavedTitle) => void;
onSetSeasons: (item: SavedTitle) => SubmitFunction;
}

let { items, onSelect, onSetSeasons }: Props = $props();
Expand Down
72 changes: 19 additions & 53 deletions src/lib/components/media/MediaCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,21 @@
watched?: boolean;
/** Extra line under the title, e.g. season progress. */
note?: string;
/**
* A TV season that has not aired, badged on the poster the same way an
* unreleased film is — the visual language for "not watchable yet" should
* not depend on whether the title is a film or a show.
*/
/** An unaired season, badged like an unreleased film: same language for both. */
upcomingSeason?: { number: number; label: string } | null;
/**
* Set on the tiles that land above the fold.
*
* `loading="lazy"` is right for a long grid but wrong for its first row:
* lazy images are invisible to the preload scanner and fetched at low
* priority, so applying it to every poster pushes the one that *is* the
* LCP element to the back of the queue.
*
* Measured against the production build on Discover: LCP 972ms -> ~885ms
* and FCP 640ms -> ~570ms. Local numbers, so the real-network gap is
* likely wider — priority hints matter most under bandwidth contention.
* Set on the tiles above the fold. Lazy images are invisible to the preload
* scanner, so lazy-loading the first row delays the very poster that is the
* LCP element. Measured on Discover: LCP 972ms -> 885ms, FCP 640ms -> 570ms.
*/
priority?: boolean;
/** When provided, the poster and title become clickable to open details. */
onSelect?: () => void;
/**
* Where the poster and title lead, when following the tile means going
* somewhere rather than opening a sheet over what is already here.
*
* A real anchor rather than a button that navigates, because a grid of
* links is a grid people open in background tabs — which is precisely what
* somebody does with a list they have been sent. Ignored when `onSelect` is
* given; a tile has one behaviour.
* Where the tile leads, when following it means navigating rather than
* opening a sheet. A real anchor, so a shared list can be opened into
* background tabs. Ignored when `onSelect` is given; a tile has one
* behaviour.
*/
href?: string;
/** Action controls rendered in the card footer (buttons, forms, badges). */
Expand All @@ -75,21 +61,15 @@
/** Which of the two follow behaviours this tile has, if either. */
const interactive = $derived(Boolean(onSelect || href));

/**
* The title's own styling, shared by the button and the anchor forms of it.
*
* Named rather than repeated: the two differ only in which element they are,
* and a class list copied into both is one that will be edited in one.
*/
/** Shared by the button and anchor forms of the title, so neither drifts. */
const TITLE_LINK =
'-my-3 block cursor-pointer py-3 text-left text-sm leading-snug font-semibold text-ink transition-colors duration-200 hover:text-brand-hi';

const poster = $derived(posterUrl(posterPath, 'w342'));
const year = $derived(releaseYear(releaseDate));
const rating = $derived(voteAverage ? voteAverage.toFixed(1) : null);

// TMDB returns titles that are still in production alongside released ones,
// so the card has to be able to say "not out yet — here's when".
// TMDB lists in-production titles alongside released ones.
const release = $derived(getReleaseInfo(releaseDate));
const unreleased = $derived(release.state !== 'released');
</script>
Expand Down Expand Up @@ -133,18 +113,10 @@
></div>

<!--
The badges below are flat, unlike the frosted header and sheet.

They used to carry `backdrop-blur-sm` for the same look, and at 65-70%
black over a poster a 4px blur is invisible — screenshots of a badge with
and without it are indistinguishable. What it was not was free: each one
is a compositing layer the compositor re-samples every frame, and there
are two per card. On a grid of 77 titles that is 154 of them, and
removing them halved the renderer's work during a scroll (794ms -> 392ms
over 300 frames, `Commit` 1.19ms -> 0.25ms per frame, 4x CPU throttle).

The two places blur is still worth its cost — the sticky header and the
sheet — are one element each and sit over genuinely moving content.
Flat, not frosted: do not add `backdrop-blur` here. Over 65-70% black a
4px blur is invisible, and each badge is a compositing layer re-sampled
every frame — two per card, 154 on a grid of 77. Removing them halved
the renderer's scroll work (794ms -> 392ms over 300 frames, 4x throttle).
-->

<span
Expand Down Expand Up @@ -225,18 +197,12 @@
<div class="flex flex-1 flex-col gap-1 p-3">
{#if interactive}
<!--
`py-3 -my-3` grows the hit area to the 44px a thumb needs without
moving the text a pixel. The poster above is the same action and a
much larger target, so this is the secondary route — but a 19px-tall
line was not a target at all.
`py-3 -my-3` buys the 44px a thumb needs without moving the text.

The clamp belongs to the span, not to the button, and the two reasons
are separate. `-webkit-line-clamp` needs a `-webkit-box`, which a
`<button>` refuses to become — it blockifies to `flow-root`, leaving
the clamp inert. And `overflow: hidden` clips at the *padding* box, so
with padding on the same element a third line still paints inside it —
straight over the year underneath, which the negative margin has pulled
up to meet it. A padding-free element clips where its text ends.
The clamp must stay on the span, for two separate reasons:
`-webkit-line-clamp` needs a `-webkit-box`, which a `<button>` refuses
to become; and `overflow: hidden` clips at the padding box, so a third
line would paint over the year the negative margin pulled up to meet.
-->
{#if onSelect}
<button type="button" onclick={onSelect} {title} class={TITLE_LINK}>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/media/WatchlistCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
shouldWarnAboutDeletion,
type DeletionWindow
} from '$lib/domain/deletion';
import type { WatchlistItem } from '$lib/server/db/schema';
import type { SavedTitle } from '$lib/domain/watchlist';

/**
* A saved title with its controls.
Expand All @@ -21,7 +21,7 @@
* completing the tracker is what marks them as watched.
*/
interface Props {
item: WatchlistItem;
item: SavedTitle;
/** Forwarded to the poster; set for the tiles above the fold. */
priority?: boolean;
/** The account's auto-delete window, or null when the feature is off. */
Expand Down
9 changes: 3 additions & 6 deletions src/lib/components/media/WatchlistCard.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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';
import type { SavedTitle } from '$lib/domain/watchlist';

/**
* Which control a saved title gets, and whether what replaces it fits.
Expand All @@ -23,16 +23,14 @@ const TILE_WIDTH = 155;
*/
const LONGEST = { mediaType: 'tv' as const, releaseDate: '2099-09-30' };

function item(over: Partial<WatchlistItem> = {}): WatchlistItem {
function item(over: Partial<SavedTitle> = {}): SavedTitle {
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,
Expand All @@ -42,12 +40,11 @@ function item(over: Partial<WatchlistItem> = {}): WatchlistItem {
nextSeasonNumber: null,
nextSeasonAirDate: null,
watchedAt: null,
addedAt: new Date(),
...over
};
}

function card(over: Partial<WatchlistItem> = {}) {
function card(over: Partial<SavedTitle> = {}) {
const container = document.createElement('div');
container.style.width = `${TILE_WIDTH}px`;
document.body.appendChild(container);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/domain/deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function isDueForDeletion(
* read. A week is long enough to notice and act, and acting is one tap: the
* warning carries the button that resets the clock.
*/
export const DELETION_WARNING_DAYS = 7;
const DELETION_WARNING_DAYS = 7;

export function shouldWarnAboutDeletion(
entry: DeletableEntry,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/domain/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface SeasonProgress {
* (an entry saved before air dates were tracked). That is the pre-existing
* behaviour, and the read-path backfill replaces it on the next visit.
*/
export function watchableSeasons(entry: TrackableEntry): number | null {
function watchableSeasons(entry: TrackableEntry): number | null {
return entry.airedSeasons ?? entry.totalSeasons;
}

Expand Down
6 changes: 3 additions & 3 deletions src/lib/domain/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export interface RecommendationRail {
}

/** Requested in parallel, so the third costs no wall-clock time — it is a spare. */
export const MAX_SEEDS = 3;
const MAX_SEEDS = 3;

/** How many rows to render. Two is enough to be useful without burying trending. */
export const MAX_RAILS = 2;
const MAX_RAILS = 2;

/** Below this a row looks like a mistake rather than a suggestion. */
export const MIN_RAIL_ITEMS = 4;
Expand All @@ -56,7 +56,7 @@ export const MIN_RAIL_ITEMS = 4;
* its suggestions — so the tail was the weakest half of an already secondary
* section.
*/
export const MAX_RAIL_ITEMS = 8;
const MAX_RAIL_ITEMS = 8;

/**
* Whether a title says anything about taste. Saving is a guess; watching is a
Expand Down
2 changes: 1 addition & 1 deletion src/lib/domain/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

/** The parts of a list somebody might be willing to publish. */
export const SHARE_SCOPES = ['toWatch', 'watched', 'both'] as const;
const SHARE_SCOPES = ['toWatch', 'watched', 'both'] as const;

export type ShareScope = (typeof SHARE_SCOPES)[number];

Expand Down
23 changes: 23 additions & 0 deletions src/lib/domain/watchlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ export interface WatchlistEntry {
nextSeasonAirDate: string | null;
}

/**
* A saved title as the interface renders one.
*
* `WatchlistEntry` is what the *rules* need — enough to filter, sort and reason
* about a title. A card needs that plus the three things only a rendering cares
* about: which row it is, what to draw, and when it was finished so the
* deletion countdown can be worked out.
*
* It exists so components can stop importing `WatchlistItem` from the database
* schema. That import was type-only, so nothing ever shipped to the browser,
* but it pointed presentation at the persistence shape: adding a column changed
* the type a card saw, and a card has no business knowing a column exists. The
* database row satisfies this structurally, so nothing has to convert anything.
*/
export interface SavedTitle extends WatchlistEntry {
id: string;
/** TMDB's identity, which is what opening or linking to the title needs. */
tmdbId: number;
posterPath: string | null;
/** When it became watched; the clock the deletion warning counts down. */
watchedAt: Date | null;
}

/** Current view options coming from the UI controls. */
export interface WatchlistView {
/** 'all' | 'toWatch' | 'inProgress' | 'upcoming' | 'watched' */
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { env } from '$env/dynamic/private';
export const GOOGLE_SCOPES = ['openid', 'profile', 'email'];

/** Path Google redirects back to. Must be registered in the Google Console. */
export const CALLBACK_PATH = '/auth/google/callback';
const CALLBACK_PATH = '/auth/google/callback';

/**
* Short-lived cookies that carry the handshake across the redirect to Google:
Expand Down
Loading