From df7503eb9b6a951b1019cb5a4bd86c4341576072 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sun, 6 Sep 2026 03:03:19 -0600 Subject: [PATCH] =?UTF-8?q?feat(watchlist):=20stop=20a=20title=20being=20m?= =?UTF-8?q?arked=20watched=20before=20it=20is=20out=20"Watched"=20sat=20on?= =?UTF-8?q?=20a=20film=20that=20opens=20next=20spring,=20which=20is=20eith?= =?UTF-8?q?er=20a=20mistake=20waiting=20to=20happen=20or=20a=20note=20the?= =?UTF-8?q?=20list=20has=20no=20way=20to=20interpret.=20The=20app=20alread?= =?UTF-8?q?y=20refuses=20to=20tick=20a=20show=20past=20its=20aired=20seaso?= =?UTF-8?q?ns;=20this=20is=20the=20same=20rule=20for=20the=20other=20half?= =?UTF-8?q?=20of=20the=20library.=20Where=20the=20button=20was,=20the=20ca?= =?UTF-8?q?rd=20now=20says=20when=20the=20title=20lands=20=E2=80=94=20"Out?= =?UTF-8?q?=20Sep=209",=20"Premieres=20Nov=205".=20Not=20a=20disabled=20bu?= =?UTF-8?q?tton:=20a=20greyed=20control=20still=20reads=20as=20an=20action?= =?UTF-8?q?=20you=20are=20failing=20to=20perform=20and=20invites=20the=20t?= =?UTF-8?q?ap=20that=20does=20nothing,=20while=20a=20date=20answers=20the?= =?UTF-8?q?=20question=20the=20button=20was=20standing=20in=20front=20of.?= =?UTF-8?q?=20The=20remove=20button=20stays,=20because=20changing=20your?= =?UTF-8?q?=20mind=20about=20waiting=20for=20something=20is=20not=20the=20?= =?UTF-8?q?same=20as=20claiming=20to=20have=20seen=20it.=20Only=20a=20conf?= =?UTF-8?q?irmed=20future=20date=20refuses.=20A=20title=20TMDB=20holds=20n?= =?UTF-8?q?o=20date=20for=20is=20still=20tickable,=20because=20that=20stat?= =?UTF-8?q?e=20means=20two=20opposite=20things=20=E2=80=94=20a=20productio?= =?UTF-8?q?n=20announced=20years=20out,=20and=20an=20obscure=20catalogue?= =?UTF-8?q?=20title=20nobody=20has=20dated=20=E2=80=94=20and=20refusing=20?= =?UTF-8?q?on=20it=20would=20lock=20a=20film=20somebody=20watched=20decade?= =?UTF-8?q?s=20ago=20on=20the=20strength=20of=20a=20missing=20field.=20Un-?= =?UTF-8?q?watching=20is=20never=20blocked=20either,=20so=20no=20row=20is?= =?UTF-8?q?=20left=20claiming=20something=20its=20owner=20cannot=20take=20?= =?UTF-8?q?back.=20The=20block=20is=20only=20as=20good=20as=20the=20date?= =?UTF-8?q?=20behind=20it,=20and=20a=20film's=20date=20was=20snapshotted?= =?UTF-8?q?=20on=20save=20and=20never=20revised:=20a=20studio=20moving=20a?= =?UTF-8?q?=20release=20forward=20would=20have=20left=20the=20card=20refus?= =?UTF-8?q?ing=20forever,=20with=20removing=20and=20re-saving=20the=20only?= =?UTF-8?q?=20way=20out.=20So=20the=20read=20path=20now=20re-asks=20about?= =?UTF-8?q?=20titles=20that=20have=20not=20come=20out,=20the=20way=20it=20?= =?UTF-8?q?already=20re-asks=20about=20seasons.=20That=20also=20fixes=20tw?= =?UTF-8?q?o=20places=20the=20stale=20date=20was=20already=20wrong=20?= =?UTF-8?q?=E2=80=94=20the=20Upcoming=20grouping,=20and=20the=20calendar?= =?UTF-8?q?=20feed,=20which=20was=20publishing=20slipped=20premieres=20int?= =?UTF-8?q?o=20people's=20calendars=20as=20appointments=20for=20a=20night?= =?UTF-8?q?=20with=20nothing=20on.=20Two=20things=20found=20while=20drivin?= =?UTF-8?q?g=20it=20in=20a=20browser:=20"In=20theaters=20In=203=20days"=20?= =?UTF-8?q?read=20like=20a=20typo,=20so=20the=20line=20takes=20the=20date?= =?UTF-8?q?=20and=20leaves=20the=20countdown=20to=20the=20poster=20badge?= =?UTF-8?q?=20above=20it;=20and=20a=20far-off=20date=20carries=20its=20yea?= =?UTF-8?q?r,=20which=20does=20not=20fit=20a=20tile=20on=20a=20five-column?= =?UTF-8?q?=20grid,=20so=20the=20line=20wraps=20rather=20than=20truncating?= =?UTF-8?q?=20the=20year=20off=20the=20end=20of=20it.=20Both=20are=20pinne?= =?UTF-8?q?d=20by=20a=20measuring=20test.=20Coming=20Soon=20also=20called?= =?UTF-8?q?=20a=20film's=20release=20a=20premiere,=20which=20only=20became?= =?UTF-8?q?=20visible=20once=20the=20card=20above=20it=20started=20saying?= =?UTF-8?q?=20"Out".?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/media/ComingSoon.svelte | 10 +- src/lib/components/media/WatchlistCard.svelte | 83 ++++++++--- .../media/WatchlistCard.svelte.spec.ts | 135 ++++++++++++++++++ src/lib/domain/release.spec.ts | 75 +++++++++- src/lib/domain/release.ts | 79 +++++++++- src/lib/server/watchlist/actions.spec.ts | 33 +++++ src/lib/server/watchlist/actions.ts | 17 +++ src/lib/server/watchlist/index.ts | 2 +- src/lib/server/watchlist/releases.ts | 37 +++++ src/lib/server/watchlist/upkeep.spec.ts | 96 ++++++++++++- src/lib/server/watchlist/upkeep.ts | 63 ++++++++ src/routes/watchlist/+page.server.ts | 18 ++- 12 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/media/WatchlistCard.svelte.spec.ts create mode 100644 src/lib/server/watchlist/releases.ts diff --git a/src/lib/components/media/ComingSoon.svelte b/src/lib/components/media/ComingSoon.svelte index 2f6c72a..c300a49 100644 --- a/src/lib/components/media/ComingSoon.svelte +++ b/src/lib/components/media/ComingSoon.svelte @@ -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'; /** @@ -80,7 +81,14 @@ {#if upcoming.kind === 'season'} Season {upcoming.seasonNumber} {:else} - Premieres + + {releaseVerb(item.mediaType)} {/if} · {upcoming.date ? upcoming.fullDate : 'date not announced'}

diff --git a/src/lib/components/media/WatchlistCard.svelte b/src/lib/components/media/WatchlistCard.svelte index 749a84a..48773dc 100644 --- a/src/lib/components/media/WatchlistCard.svelte +++ b/src/lib/components/media/WatchlistCard.svelte @@ -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, @@ -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'); /** @@ -120,21 +139,51 @@ {:else}
-
- - -
+ + + {pendingRelease} +

+ {:else} +
+ + +
+ {/if}
diff --git a/src/lib/components/media/WatchlistCard.svelte.spec.ts b/src/lib/components/media/WatchlistCard.svelte.spec.ts new file mode 100644 index 0000000..8d7bf8d --- /dev/null +++ b/src/lib/components/media/WatchlistCard.svelte.spec.ts @@ -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 { + 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 = {}) { + 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); + }); +}); diff --git a/src/lib/domain/release.spec.ts b/src/lib/domain/release.spec.ts index 077ed52..dfd9ec2 100644 --- a/src/lib/domain/release.spec.ts +++ b/src/lib/domain/release.spec.ts @@ -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'); @@ -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(); + }); +}); diff --git a/src/lib/domain/release.ts b/src/lib/domain/release.ts index fbcccb4..c720b9b 100644 --- a/src/lib/domain/release.ts +++ b/src/lib/domain/release.ts @@ -23,6 +23,15 @@ export interface ReleaseInfo { daysUntil: number | null; /** Compact badge text, e.g. "Tomorrow", "In 5 days", "Aug 14", "Mar 2027". */ shortLabel: string; + /** + * The date itself, always, at the precision it was given: "Aug 14", + * "Mar 2027", "2028". + * + * `shortLabel` turns into a countdown inside the last week, which is the + * friendlier thing on a badge and the wrong thing to put after a verb — + * "In theaters In 3 days" reads like a typo. Empty unless there is a date. + */ + shortDate: string; /** Full sentence for detail views, e.g. "Friday, August 14, 2026". */ fullDate: string; } @@ -39,22 +48,30 @@ const MS_PER_DAY = 86_400_000; export function getReleaseInfo(releaseDate: string | null, now: Date = new Date()): ReleaseInfo { const parsed = parseReleaseDate(releaseDate); if (parsed === null) { - return { state: 'unscheduled', daysUntil: null, shortLabel: 'TBA', fullDate: '' }; + return { + state: 'unscheduled', + daysUntil: null, + shortLabel: 'TBA', + shortDate: '', + fullDate: '' + }; } const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); const daysUntil = Math.round((parsed.timestamp - todayUtc) / MS_PER_DAY); const date = new Date(parsed.timestamp); const fullDate = formatDate(date, parsed.precision, now.getFullYear(), true); + const shortDate = formatDate(date, parsed.precision, now.getFullYear(), false); if (daysUntil <= 0) { - return { state: 'released', daysUntil: null, shortLabel: '', fullDate }; + return { state: 'released', daysUntil: null, shortLabel: '', shortDate, fullDate }; } return { state: 'upcoming', daysUntil, shortLabel: formatShort(date, parsed.precision, daysUntil, now.getFullYear()), + shortDate, fullDate }; } @@ -147,3 +164,61 @@ function formatDate( function format(date: Date, options: Intl.DateTimeFormatOptions): string { return new Intl.DateTimeFormat('en-US', { ...options, timeZone: 'UTC' }).format(date); } + +/** + * Whether a title may be marked as watched yet. + * + * The app already refuses to let a show be ticked past its aired seasons — you + * cannot have watched what has not been broadcast — and this is the same rule + * for the other half of the library. Without it "Watched" sits on a film that + * opens next spring, which is either a mistake waiting to happen or a note the + * list has no way to interpret. + * + * Only a confirmed future date refuses. `unscheduled` — TMDB holding no date at + * all — deliberately does not, because that state means two opposite things: a + * production announced years out, and an obscure catalogue title nobody has + * dated. Refusing on it would lock a film somebody watched decades ago on the + * strength of a missing field. Not knowing when something came out is not the + * same as knowing it has not. + * + * Marking something *un*watched is never blocked; see `toggleWatched`. This + * governs the way in, not the way back out, so a row that predates the rule is + * never stranded. + */ +export function canMarkWatched(releaseDate: string | null, now: Date = new Date()): boolean { + return !isUpcoming(releaseDate, now); +} + +/** + * The compact verb, for the one slot that has no room for `releaseVerb`. + * + * "In theaters Mar 2027" overflows a poster tile on a five-column grid and + * truncates to "In theaters Mar 20…", which loses the only part that was worth + * saying. "Out" costs eight characters and no meaning: the tile already wears a + * FILM or TV badge, so the verb is not what distinguishes them here — it is only + * there to stop a bare date reading as a year of production. + */ +function compactReleaseVerb(mediaType: MediaType): string { + return mediaType === 'tv' ? 'Premieres' : 'Out'; +} + +/** + * What the card says where the "Watched" button would have been. + * + * The verb matters as much as the date: "Aug 14" alone does not say whether a + * film opens or a series premieres, and the card has room for exactly one line. + * + * The date rather than `shortLabel`, which becomes a countdown inside the last + * week and would read "Out In 3 days". The countdown is not lost — it is on the + * poster badge directly above, which is the better place for it: one glance says + * how soon, the line underneath says what and when. + */ +export function pendingReleaseLabel( + mediaType: MediaType, + releaseDate: string | null, + now: Date = new Date() +): string | null { + const release = getReleaseInfo(releaseDate, now); + if (release.state !== 'upcoming') return null; + return `${compactReleaseVerb(mediaType)} ${release.shortDate}`; +} diff --git a/src/lib/server/watchlist/actions.spec.ts b/src/lib/server/watchlist/actions.spec.ts index 1afc836..c2b6982 100644 --- a/src/lib/server/watchlist/actions.spec.ts +++ b/src/lib/server/watchlist/actions.spec.ts @@ -156,6 +156,39 @@ describe('toggleWatched', () => { await call('toggleWatched', { id: 'item-1' }); expect((await read()).watchedAt).toBeNull(); }); + + /** + * You cannot have watched what has not come out. The card renders the release + * date instead of the button, so this is the guard for a form body that card + * did not build. + */ + it('refuses to mark a title watched before it is released', async () => { + await saveShow({ releaseDate: '2999-01-01' }); + + expect(await call('toggleWatched', { id: 'item-1' })).toMatchObject({ status: 400 }); + expect(await read()).toMatchObject({ watched: false }); + }); + + // Not knowing when something came out is not knowing that it has not; see + // `canMarkWatched`. + it('still allows a title TMDB has no date for', async () => { + await saveShow({ releaseDate: null }); + + await call('toggleWatched', { id: 'item-1' }); + expect(await read()).toMatchObject({ watched: true }); + }); + + /** + * Only the way in is guarded. A row saved before the rule, or one whose date + * TMDB has since moved outwards, must never be stuck claiming something its + * owner cannot take back. + */ + it('always allows un-watching, even once the date has moved into the future', async () => { + await saveShow({ releaseDate: '2999-01-01', watched: true, seasonsSeen: 3 }); + + await call('toggleWatched', { id: 'item-1' }); + expect(await read()).toMatchObject({ watched: false }); + }); }); describe('setEpisode', () => { diff --git a/src/lib/server/watchlist/actions.ts b/src/lib/server/watchlist/actions.ts index b8428e8..1d174b7 100644 --- a/src/lib/server/watchlist/actions.ts +++ b/src/lib/server/watchlist/actions.ts @@ -11,6 +11,7 @@ import { } from '$lib/domain/progress'; import { resolveEpisodeTarget, seasonBoundary } from '$lib/domain/episodes'; import { normalizeDeletionWindow } from '$lib/domain/deletion'; +import { canMarkWatched } from '$lib/domain/release'; import { resolveSeasonInfo, safeDetails, seasonInfoForSave } from './seasons'; import { watchedStamp } from './stamp'; import { issueCalendarToken, revokeCalendarToken } from '../calendar'; @@ -213,6 +214,8 @@ export const watchlistActions = { const db = getDb(); const [item] = await db .select({ + title: watchlistItem.title, + releaseDate: watchlistItem.releaseDate, watched: watchlistItem.watched, watchedAt: watchlistItem.watchedAt, totalSeasons: watchlistItem.totalSeasons, @@ -224,6 +227,20 @@ export const watchlistActions = { if (!item) return fail(404, { message: 'Item not found.' }); const watched = !item.watched; + + /** + * A title that is not out yet cannot be marked watched — the same rule the + * aired-seasons ceiling below already applies to shows, said once more for + * films. The card renders the release date instead of the button, so + * reaching here means a form body that was not built by that card. + * + * Only the way in is guarded. Un-watching stays open so a row saved before + * this rule, or one whose date TMDB has since moved outwards, is never + * stuck claiming something its owner cannot take back. + */ + if (watched && !canMarkWatched(item.releaseDate)) { + return fail(400, { message: `“${item.title}” isn't out yet.` }); + } // Only aired seasons can be ticked off, so "mark watched" lands on the last // broadcast season rather than on an announced one. const ceiling = item.airedSeasons ?? item.totalSeasons; diff --git a/src/lib/server/watchlist/index.ts b/src/lib/server/watchlist/index.ts index 638121b..ba947ec 100644 --- a/src/lib/server/watchlist/index.ts +++ b/src/lib/server/watchlist/index.ts @@ -15,4 +15,4 @@ */ export { loadWatchlist, countToWatch, findSavedEntry, type WatchlistRow } from './queries'; export { watchlistActions } from './actions'; -export { refreshSeasonData, deleteExpired } from './upkeep'; +export { refreshSeasonData, refreshReleaseDates, deleteExpired } from './upkeep'; diff --git a/src/lib/server/watchlist/releases.ts b/src/lib/server/watchlist/releases.ts new file mode 100644 index 0000000..75ad0fd --- /dev/null +++ b/src/lib/server/watchlist/releases.ts @@ -0,0 +1,37 @@ +import { getDetails } from '../tmdb'; +import type { MediaType } from '$lib/types'; + +/** + * Asking TMDB when a title actually comes out, now that the answer matters. + * + * A release date used to be decoration: a year under a poster, a badge, a line + * in the calendar feed. Being wrong cost nothing anybody would notice. It stops + * being decoration the moment "Watched" is withheld on the strength of it — + * a date that has quietly moved is then a card its owner cannot use. + * + * The snapshot taken when a title is saved is never revised anywhere else, and + * release dates move constantly: films slip, films are pulled forward, and a + * placeholder year becomes a real day. So the read path re-asks, exactly the way + * it already re-asks about seasons. + */ + +/** + * The current release date for one title, or null when TMDB could not be asked. + * + * Null means "no answer", not "no date" — the caller leaves the row alone rather + * than writing an absence over a date it already had. TMDB genuinely having no + * date comes back as the string being absent, which is a value worth storing: + * a date that was withdrawn is news. + */ +export async function resolveReleaseDate( + mediaType: MediaType, + tmdbId: number +): Promise<{ releaseDate: string | null } | null> { + try { + const details = await getDetails(mediaType, tmdbId); + return { releaseDate: details.releaseDate }; + } catch (err) { + console.error('Failed to read release date for %s/%d:', mediaType, tmdbId, err); + return null; + } +} diff --git a/src/lib/server/watchlist/upkeep.spec.ts b/src/lib/server/watchlist/upkeep.spec.ts index 22b500c..c2eb450 100644 --- a/src/lib/server/watchlist/upkeep.spec.ts +++ b/src/lib/server/watchlist/upkeep.spec.ts @@ -19,9 +19,12 @@ import type { WatchlistRow } from './queries'; let harness: TestDatabase; vi.mock('../db', () => ({ getDb: () => harness.db })); -vi.mock('../tmdb', () => ({ getDetails: vi.fn() })); -const { deleteExpired } = await import('./upkeep'); +/** TMDB's answer, stubbed. The network is not what is under test here. */ +const details = vi.hoisted(() => vi.fn()); +vi.mock('../tmdb', () => ({ getDetails: details })); + +const { deleteExpired, refreshReleaseDates } = await import('./upkeep'); const DAY = 86_400_000; const daysAgo = (n: number) => new Date(Date.now() - n * DAY); @@ -53,10 +56,16 @@ async function survivors(): Promise { return rows.map((row) => row.title).sort(); } +/** A date `days` from today, as TMDB writes them. */ +function isoIn(days: number): string { + return new Date(Date.now() + days * DAY).toISOString().slice(0, 10); +} + beforeEach(async () => { harness = await createTestDatabase(); await seedUser(harness.db, { id: 'alice', googleId: 'g-alice', email: 'alice@example.test' }); await seedUser(harness.db, { id: 'bob', googleId: 'g-bob', email: 'bob@example.test' }); + details.mockReset(); }); describe('deleteExpired', () => { @@ -133,3 +142,86 @@ describe('deleteExpired', () => { expect(row.userId).toBe('bob'); }); }); + +describe('refreshReleaseDates', () => { + /** What the row holds now. */ + const storedDate = async (id: string) => + (await harness.db.select().from(watchlistItem).where(eq(watchlistItem.id, id)))[0].releaseDate; + + it('asks about nothing when every title is already out', async () => { + const out = await saveTitle('alice', { releaseDate: '2016-11-11' }); + + expect(await refreshReleaseDates([out])).toEqual([out]); + expect(details).not.toHaveBeenCalled(); + }); + + /** + * The case the whole function exists for: a film pulled forward. Without this + * the card keeps refusing "Watched" against a date that is no longer true, + * and the only way out is removing the title and saving it again. + */ + it('brings a title forward once TMDB moves it', async () => { + const waiting = await saveTitle('alice', { releaseDate: isoIn(400), watched: false }); + details.mockResolvedValue({ releaseDate: '2020-01-01' }); + + const [patched] = await refreshReleaseDates([waiting]); + + expect(patched.releaseDate).toBe('2020-01-01'); + expect(await storedDate(waiting.id)).toBe('2020-01-01'); + }); + + it('records a date that slipped further out', async () => { + const waiting = await saveTitle('alice', { releaseDate: isoIn(10), watched: false }); + details.mockResolvedValue({ releaseDate: isoIn(900) }); + + const [patched] = await refreshReleaseDates([waiting]); + + expect(patched.releaseDate).toBe(isoIn(900)); + }); + + // A date that was withdrawn is news; the row should stop claiming a day. + it('records a date being withdrawn', async () => { + const waiting = await saveTitle('alice', { releaseDate: isoIn(30), watched: false }); + details.mockResolvedValue({ releaseDate: null }); + + const [patched] = await refreshReleaseDates([waiting]); + + expect(patched.releaseDate).toBeNull(); + expect(await storedDate(waiting.id)).toBeNull(); + }); + + /** + * Null from TMDB means "no answer", not "no date". Writing an absence over a + * date we already had would turn one failed request into a lost release. + */ + it('leaves the row alone when TMDB cannot be reached', async () => { + const waiting = await saveTitle('alice', { releaseDate: isoIn(30), watched: false }); + details.mockRejectedValue(new Error('network')); + + const [patched] = await refreshReleaseDates([waiting]); + + expect(patched.releaseDate).toBe(isoIn(30)); + expect(await storedDate(waiting.id)).toBe(isoIn(30)); + }); + + // What keeps a list of patient titles from issuing a write per row on every + // single page load. + it('writes nothing when the date has not changed', async () => { + const waiting = await saveTitle('alice', { releaseDate: isoIn(30), watched: false }); + details.mockResolvedValue({ releaseDate: isoIn(30) }); + + expect(await refreshReleaseDates([waiting])).toEqual([waiting]); + }); + + it('caps how many titles one page load re-asks about', async () => { + const waiting = []; + for (let i = 0; i < 12; i++) { + waiting.push(await saveTitle('alice', { releaseDate: isoIn(50 + i), watched: false })); + } + details.mockResolvedValue({ releaseDate: '2020-01-01' }); + + await refreshReleaseDates(waiting); + + expect(details.mock.calls.length).toBeLessThanOrEqual(8); + }); +}); diff --git a/src/lib/server/watchlist/upkeep.ts b/src/lib/server/watchlist/upkeep.ts index da991da..a14c206 100644 --- a/src/lib/server/watchlist/upkeep.ts +++ b/src/lib/server/watchlist/upkeep.ts @@ -4,7 +4,9 @@ import { watchlistItem } from '../db/schema'; import { clampSeasons, deriveWatched } from '$lib/domain/progress'; import { carryBookmark } from '$lib/domain/episodes'; import { isDueForDeletion, type DeletionWindow } from '$lib/domain/deletion'; +import { isUpcoming } from '$lib/domain/release'; import { resolveSeasonInfo } from './seasons'; +import { resolveReleaseDate } from './releases'; import { watchedStamp } from './stamp'; import type { WatchlistRow } from './queries'; @@ -109,6 +111,67 @@ export async function refreshSeasonData(items: WatchlistRow[]): Promise (patches.has(item.id) ? { ...item, ...patches.get(item.id) } : item)); } +/** + * Re-ask TMDB about titles that have not come out yet, and return the list with + * those rows patched. + * + * Only rows still waiting are re-read, which is what bounds the work: once a + * title is out its date stops being asked about forever, so this converges to + * nothing for the great majority of a list. What it costs is one request per + * title somebody is waiting on, and what it buys is that a date is never more + * than one page load stale. + * + * That matters in three places, and it mattered in two of them before this rule + * existed. "Watched" is withheld on this date, so a film pulled forward would + * otherwise be untickable until it was removed and saved again. The Upcoming tab + * groups by it. And the calendar feed publishes it into people's calendars, + * where a slipped premiere becomes an appointment for a night with nothing on. + * + * Failures are non-fatal by construction: `resolveReleaseDate` answers null and + * the row keeps the date it had. + */ +export async function refreshReleaseDates(items: WatchlistRow[]): Promise { + const now = new Date(); + const pending = items + .filter((item) => isUpcoming(item.releaseDate, now)) + .slice(0, BACKFILL_BATCH_SIZE); + if (pending.length === 0) return items; + + const resolved = await Promise.all( + pending.map(async (item) => ({ + id: item.id, + current: item.releaseDate, + answer: await resolveReleaseDate(item.mediaType, item.tmdbId) + })) + ); + + const db = getDb(); + const patches = new Map(); + + await Promise.all( + resolved.map(async ({ id, current, answer }) => { + // No answer, or the same answer: nothing to write. Skipping the unchanged + // case is what keeps a list of patient titles from issuing a write per + // row on every single page load. + if (!answer || answer.releaseDate === current) return; + + patches.set(id, answer.releaseDate); + await db + .update(watchlistItem) + .set({ releaseDate: answer.releaseDate }) + .where(eq(watchlistItem.id, id)); + }) + ); + + if (patches.size === 0) return items; + + // Patch the in-memory copy too, so a title that came out early is tickable on + // this render rather than only after the visitor happens to reload. + return items.map((item) => + patches.has(item.id) ? { ...item, releaseDate: patches.get(item.id) ?? null } : item + ); +} + /** * Delete watched entries whose window has elapsed, returning the surviving list. * diff --git a/src/routes/watchlist/+page.server.ts b/src/routes/watchlist/+page.server.ts index 2911267..2ea365f 100644 --- a/src/routes/watchlist/+page.server.ts +++ b/src/routes/watchlist/+page.server.ts @@ -4,6 +4,7 @@ import { user } from '$lib/server/db/schema'; import { deleteExpired, loadWatchlist, + refreshReleaseDates, refreshSeasonData, watchlistActions, type WatchlistRow @@ -44,15 +45,22 @@ export const load: PageServerLoad = async ({ locals }) => { const autoDeleteDays = normalizeDeletionWindow(row?.autoDeleteDays); /** - * Two pieces of upkeep, both on the read path so there is no scheduled job to - * own. Season data is resolved first because auto-deletion reads it: a show - * that just gained a season must stop being eligible *before* the deletion - * rule looks at it, or being caught up would destroy the very title whose next + * Three pieces of upkeep, all on the read path so there is no scheduled job to + * own, and the order between them is load-bearing. + * + * Season data is resolved first because auto-deletion reads it: a show that + * just gained a season must stop being eligible *before* the deletion rule + * looks at it, or being caught up would destroy the very title whose next * season is now airing. + * + * Release dates are re-asked next, and only for titles still waiting. That is + * what keeps "Watched" from being withheld on a date that has quietly moved — + * a film pulled forward would otherwise stay untickable until it was removed + * and saved again. */ const items = await deleteExpired( locals.user.id, - await refreshSeasonData(await loadWatchlist(locals.user.id)), + await refreshReleaseDates(await refreshSeasonData(await loadWatchlist(locals.user.id))), autoDeleteDays );