fix(upcoming): make feed cursor unicode-safe and consistent with sort order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-04 15:06:59 -04:00
1 parent 2e7f2667dc
commit d73529586b
2 files changed
+122 -31

No files matched your search

+67 -31
View File
@@ -380,6 +380,51 @@ export interface UpcomingFeedResult {
nextCursor: string | null; nextCursor: string | null;
} }
/** Sort/cursor key for one upcoming feed item. `s`/`e` are 0 for movies. */
interface UpcomingCursorKey {
d: string; // date YYYY-MM-DD
n: string; // title name
i: string; // title id
s: number; // season number
e: number; // episode number
}
function compareUpcomingKeys(a: UpcomingCursorKey, b: UpcomingCursorKey): number {
return (
a.d.localeCompare(b.d) ||
a.n.localeCompare(b.n) ||
a.i.localeCompare(b.i) ||
a.s - b.s ||
a.e - b.e
);
}
function encodeUpcomingCursor(key: UpcomingCursorKey): string {
return Buffer.from(JSON.stringify(key), "utf8").toString("base64url");
}
function decodeUpcomingCursor(cursor: string): UpcomingCursorKey | null {
try {
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
if (
typeof parsed?.d !== "string" ||
typeof parsed?.n !== "string" ||
typeof parsed?.i !== "string"
) {
return null;
}
return {
d: parsed.d,
n: parsed.n,
i: parsed.i,
s: typeof parsed.s === "number" ? parsed.s : 0,
e: typeof parsed.e === "number" ? parsed.e : 0,
};
} catch {
return null;
}
}
export function getUpcomingFeed( export function getUpcomingFeed(
userId: string, userId: string,
options: { options: {
@@ -403,27 +448,20 @@ export function getUpcomingFeed(
// Use the cursor date as the lower bound so later pages skip already-seen dates, // Use the cursor date as the lower bound so later pages skip already-seen dates,
// but don't apply a DB-level LIMIT so same-day items aren't truncated. // but don't apply a DB-level LIMIT so same-day items aren't truncated.
let cursorDate: string | undefined; const cursorKey = cursor ? decodeUpcomingCursor(cursor) : null;
let cursorName: string | undefined; const fromDate = cursorKey?.d ?? today;
let cursorId: string | undefined;
if (cursor) {
try {
const parsed = JSON.parse(atob(cursor));
cursorDate = parsed.d;
cursorName = parsed.n;
cursorId = parsed.i;
} catch {
// Invalid cursor — ignore
}
}
const fromDate = cursorDate ?? today;
const episodeRows = const episodeRows =
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses); mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
const movieRows = const movieRows =
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses); mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
// Merge into unified items // Merge into unified items
type RawItem = { date: string; titleId: string; titleName: string } & ( type RawItem = {
date: string;
titleId: string;
titleName: string;
key: UpcomingCursorKey;
} & (
| { type: "tv"; row: (typeof episodeRows)[number] } | { type: "tv"; row: (typeof episodeRows)[number] }
| { type: "movie"; row: (typeof movieRows)[number] } | { type: "movie"; row: (typeof movieRows)[number] }
); );
@@ -433,6 +471,13 @@ export function getUpcomingFeed(
date: r.airDate!, date: r.airDate!,
titleId: r.titleId, titleId: r.titleId,
titleName: r.titleName, titleName: r.titleName,
key: {
d: r.airDate!,
n: r.titleName,
i: r.titleId,
s: r.seasonNumber,
e: r.episodeNumber,
},
type: "tv" as const, type: "tv" as const,
row: r, row: r,
})), })),
@@ -440,18 +485,14 @@ export function getUpcomingFeed(
date: r.releaseDate!, date: r.releaseDate!,
titleId: r.titleId, titleId: r.titleId,
titleName: r.titleName, titleName: r.titleName,
key: { d: r.releaseDate!, n: r.titleName, i: r.titleId, s: 0, e: 0 },
type: "movie" as const, type: "movie" as const,
row: r, row: r,
})), })),
]; ];
// Sort by date ASC, then title ASC, then titleId for deterministic tiebreak // Sort by (date, title, titleId, season, episode) ASC — the same key the cursor uses
merged.sort( merged.sort((a, b) => compareUpcomingKeys(a.key, b.key));
(a, b) =>
a.date.localeCompare(b.date) ||
a.titleName.localeCompare(b.titleName) ||
a.titleId.localeCompare(b.titleId),
);
// Collapse batch drops: group 3+ episodes from the same title on the same date into one item // Collapse batch drops: group 3+ episodes from the same title on the same date into one item
const collapsed: (RawItem & { episodeCount: number })[] = []; const collapsed: (RawItem & { episodeCount: number })[] = [];
@@ -488,15 +529,10 @@ export function getUpcomingFeed(
} }
// Apply cursor: skip items at or before the cursor position. // Apply cursor: skip items at or before the cursor position.
// Cursor is base64-encoded JSON {d, n, i} matching the sort key (date, titleName, titleId). // Cursor is base64url-encoded UTF-8 JSON {d, n, i, s, e} matching the sort key.
let startIdx = 0; let startIdx = 0;
if (cursorDate && cursorName && cursorId) { if (cursorKey) {
startIdx = collapsed.findIndex( startIdx = collapsed.findIndex((item) => compareUpcomingKeys(item.key, cursorKey) > 0);
(item) =>
item.date > cursorDate ||
(item.date === cursorDate && item.titleName > cursorName) ||
(item.date === cursorDate && item.titleName === cursorName && item.titleId > cursorId),
);
if (startIdx === -1) startIdx = collapsed.length; if (startIdx === -1) startIdx = collapsed.length;
} }
@@ -507,7 +543,7 @@ export function getUpcomingFeed(
let nextCursor: string | null = null; let nextCursor: string | null = null;
if (hasMore && pageItems.length > 0) { if (hasMore && pageItems.length > 0) {
const last = pageItems.at(-1)!; const last = pageItems.at(-1)!;
nextCursor = btoa(JSON.stringify({ d: last.date, n: last.titleName, i: last.titleId })); nextCursor = encodeUpcomingCursor(last.key);
} }
// Batch-fetch display statuses and streaming providers // Batch-fetch display statuses and streaming providers
+55
View File
@@ -220,6 +220,61 @@ describe("cursor pagination", () => {
}); });
}); });
// ── Cursor pagination edge cases ────────────────────────────────────
function collectAllTitles(limit: number) {
const names: string[] = [];
let cursor: string | undefined;
for (let guard = 0; guard < 50; guard++) {
const page = getUpcomingFeed("user-1", { days: 30, limit, cursor });
names.push(...page.items.map((i) => `${i.titleName}#${i.episodeNumber ?? "-"}`));
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
return names;
}
describe("cursor pagination edge cases", () => {
test("handles non-Latin-1 title names at a page boundary", () => {
insertTvShow("tv-s", 201, 1, 1, { title: "Shōgun", airDates: [daysFromNow(1)] });
insertTvShow("tv-z", 202, 1, 1, { title: "Zzz Show", airDates: [daysFromNow(2)] });
insertStatus("user-1", "tv-s", "in_progress");
insertStatus("user-1", "tv-z", "in_progress");
const page1 = getUpcomingFeed("user-1", { days: 30, limit: 1 });
expect(page1.items.map((i) => i.titleName)).toEqual(["Shōgun"]);
expect(page1.nextCursor).not.toBeNull();
const page2 = getUpcomingFeed("user-1", { days: 30, limit: 1, cursor: page1.nextCursor! });
expect(page2.items.map((i) => i.titleName)).toEqual(["Zzz Show"]);
});
test("does not skip mixed-case names airing on the same date", () => {
const d = daysFromNow(1);
insertTvShow("tv-a", 211, 1, 1, { title: "apple show", airDates: [d] });
insertTvShow("tv-b", 212, 1, 1, { title: "Banana Show", airDates: [d] });
insertTvShow("tv-c", 213, 1, 1, { title: "cherry show", airDates: [d] });
insertStatus("user-1", "tv-a", "in_progress");
insertStatus("user-1", "tv-b", "in_progress");
insertStatus("user-1", "tv-c", "in_progress");
const all = getUpcomingFeed("user-1", { days: 30, limit: 50 }).items.map(
(i) => `${i.titleName}#${i.episodeNumber ?? "-"}`,
);
const paged = collectAllTitles(1);
expect(paged).toHaveLength(3);
expect(paged).toEqual(all);
});
test("does not skip a second same-day episode split across pages", () => {
const d = daysFromNow(1);
insertTvShow("tv-x", 300, 1, 2, { title: "Pair Show", airDates: [d, d] });
insertStatus("user-1", "tv-x", "in_progress");
expect(collectAllTitles(1)).toEqual(["Pair Show#1", "Pair Show#2"]);
});
});
// ── isNewSeason ───────────────────────────────────────────────────── // ── isNewSeason ─────────────────────────────────────────────────────
describe("isNewSeason", () => { describe("isNewSeason", () => {