Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
112 lines
4.4 KiB
TypeScript
112 lines
4.4 KiB
TypeScript
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
|
|
//
|
|
// Extracted from the component so it can be unit-tested: the strip must never
|
|
// collapse to just the current episode while real siblings exist, must not
|
|
// mistake number-less episodes for the current one, and must run past a season
|
|
// boundary rather than dead-ending at the end of a season (ux-flows §5B.2).
|
|
//
|
|
// TRACES: UR-048, UR-062 | DR-062, DR-104
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
/** Episodes shown before / after the current one in the strip window. */
|
|
const BEFORE = 3;
|
|
const AFTER = 6;
|
|
|
|
/** Jellyfin puts specials in season 0; they air outside the numbered run. */
|
|
const SPECIALS_SEASON = 0;
|
|
|
|
/**
|
|
* Does `ep` refer to the same episode as `current`?
|
|
*
|
|
* Matches by id first. Falls back to season+episode number, but ONLY when both
|
|
* numbers are known on both sides — otherwise `undefined === undefined` would
|
|
* mark every number-less episode as the current one (the bug that made the
|
|
* whole strip look like the current episode).
|
|
*/
|
|
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
|
if (ep.id === current.id) return true;
|
|
if (
|
|
ep.indexNumber == null ||
|
|
current.indexNumber == null ||
|
|
ep.parentIndexNumber == null ||
|
|
current.parentIndexNumber == null
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
ep.parentIndexNumber === current.parentIndexNumber && ep.indexNumber === current.indexNumber
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sort key for a season: specials (season 0) come *after* every numbered
|
|
* season, matching how a viewer works through a show — S1, S2, …, then the
|
|
* extras — rather than opening on a special because 0 < 1.
|
|
*/
|
|
function seasonRank(seasonNumber: number | null | undefined): number {
|
|
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!;
|
|
}
|
|
|
|
/**
|
|
* Broadcast order across a whole series: season ascending, then episode.
|
|
*
|
|
* When *either* side's season is unknown there is no season axis to compare on,
|
|
* so it falls through to episode number. That makes the comparator technically
|
|
* non-transitive across such a mix, which is safe here because only the
|
|
* directly-fetched `current` episode can lack a season and it is never part of
|
|
* the array being sorted — it is only positioned against it (see
|
|
* `adjacentEpisodes`).
|
|
*/
|
|
export function compareSeriesOrder(a: MediaItem, b: MediaItem): number {
|
|
if (a.parentIndexNumber != null && b.parentIndexNumber != null) {
|
|
const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber);
|
|
if (bySeason !== 0) return bySeason;
|
|
}
|
|
return (a.indexNumber ?? 0) - (b.indexNumber ?? 0);
|
|
}
|
|
|
|
/**
|
|
* The window of episodes shown under the hero: up to 3 before and 6 after the
|
|
* current episode, in series order across *all* seasons.
|
|
*
|
|
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
|
|
* finale should offer the next season's premiere, not an empty strip. Degrades
|
|
* gracefully:
|
|
* - splices the current episode into the pool at its ordered position when it
|
|
* isn't present (an API id mismatch on a directly-fetched episode), so it
|
|
* still anchors the window;
|
|
* - returns just `[current]` only when there genuinely are no other episodes.
|
|
*/
|
|
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
|
const pool = allEpisodes.slice().sort(compareSeriesOrder);
|
|
|
|
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
|
|
|
if (idx === -1) {
|
|
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
|
|
idx = insertAt === -1 ? pool.length : insertAt;
|
|
pool.splice(idx, 0, current);
|
|
}
|
|
|
|
const start = Math.max(0, idx - BEFORE);
|
|
const end = Math.min(pool.length, idx + AFTER + 1);
|
|
return pool.slice(start, end);
|
|
}
|
|
|
|
/**
|
|
* Label for a strip card, relative to the episode in focus.
|
|
*
|
|
* Within the current season a bare number reads cleanly ("6."). Once the window
|
|
* crosses into another season that number is ambiguous, so the card names the
|
|
* season too ("S3E1") — otherwise the premiere after a finale just reads "1."
|
|
*/
|
|
export function stripCardLabel(ep: MediaItem, current: MediaItem): string {
|
|
const crossesSeason =
|
|
ep.parentIndexNumber != null &&
|
|
current.parentIndexNumber != null &&
|
|
ep.parentIndexNumber !== current.parentIndexNumber;
|
|
|
|
if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`;
|
|
return `${ep.indexNumber ?? 0}.`;
|
|
}
|