fix(discovery): only offer aired episodes in Continue Watching; unify "today"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-04 15:17:11 -04:00
1 parent 72d9f8f2d1
commit 2529b132ef
7 files changed
+163 -43

No files matched your search

+21 -14
View File
@@ -23,27 +23,34 @@ export function getNextEpisode(
seasons: Season[],
watchedEpisodeIds: Set<string>,
): NextEpisodeResult {
const today = new Date().toISOString().slice(0, 10);
const now = new Date();
// Device-local calendar date; TMDB air dates are date-only.
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
let nextEpisode: NextEpisodeInfo | null = null;
let totalEpisodes = 0;
let watchedEpisodes = 0;
for (const season of seasons) {
for (const ep of season.episodes) {
totalEpisodes++;
// Undated (TBA) episodes are treated as unaired.
const aired = ep.airDate != null && ep.airDate <= today;
if (watchedEpisodeIds.has(ep.id)) {
watchedEpisodes++;
} else if (!nextEpisode) {
// Skip episodes not yet aired
if (ep.airDate && ep.airDate > today) continue;
nextEpisode = {
id: ep.id,
seasonNumber: season.seasonNumber,
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
stillThumbHash: ep.stillThumbHash,
};
if (aired) {
totalEpisodes++;
watchedEpisodes++;
}
} else if (aired) {
totalEpisodes++;
if (!nextEpisode) {
nextEpisode = {
id: ep.id,
seasonNumber: season.seasonNumber,
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
stillThumbHash: ep.stillThumbHash,
};
}
}
}
}
+11
View File
@@ -23,3 +23,14 @@ export const TMDB_IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://i
// ─── Watch providers ──────────────────────────────────────────
export const WATCH_REGION = process.env.WATCH_REGION || "US";
// ─── Dates ───────────────────────────────────────────────────
/**
* Calendar date (YYYY-MM-DD) in the server's local timezone. TMDB air/release
* dates are date-only, so "has it aired?" must compare against one consistent
* notion of today — the server's (set via TZ).
*/
export function localDateString(d: Date = new Date()): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
+23 -21
View File
@@ -1,3 +1,4 @@
import { localDateString } from "@sofa/config";
import {
getAllTrackedTitleIds,
getAvailabilityByTitleIds,
@@ -25,10 +26,6 @@ import { tmdbImageUrl } from "@sofa/tmdb/image";
import type { DisplayStatus } from "./display-status";
import { getDisplayStatusesByTitleIds } from "./tracking";
function formatLocalDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
export function periodStartTimestamp(period: TimePeriod): number {
@@ -234,7 +231,8 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
}
const items: ContinueWatchingItem[] = [];
const today = new Date().toISOString().slice(0, 10);
const today = localDateString();
const isAired = (airDate: string | null) => airDate != null && airDate <= today;
for (const row of inProgress) {
const title = titleMap.get(row.titleId);
@@ -248,27 +246,31 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
for (const s of titleSeasonsArr) {
const eps = episodesBySeason.get(s.id) ?? [];
totalEpisodes += eps.length;
for (const ep of eps) {
const aired = isAired(ep.airDate);
if (watchedEpisodeIds.has(ep.id)) {
watchedEpisodes++;
const watchDate = watchDateMap.get(ep.id);
if (watchDate && (!lastWatchedAt || watchDate > lastWatchedAt)) {
lastWatchedAt = watchDate;
}
} else if (!nextEpisode) {
// Skip episodes not yet aired
if (ep.airDate && ep.airDate > today) continue;
nextEpisode = {
id: ep.id,
seasonNumber: s.seasonNumber,
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
stillThumbHash: ep.stillThumbHash,
overview: ep.overview,
};
if (aired) {
totalEpisodes++;
watchedEpisodes++;
}
} else if (aired) {
totalEpisodes++;
if (!nextEpisode) {
nextEpisode = {
id: ep.id,
seasonNumber: s.seasonNumber,
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
stillThumbHash: ep.stillThumbHash,
overview: ep.overview,
};
}
}
}
}
@@ -441,10 +443,10 @@ export function getUpcomingFeed(
const storedStatuses = statusFilter?.map((s) => (s === "watching" ? "in_progress" : s));
const now = new Date();
const today = formatLocalDate(now);
const today = localDateString(now);
const horizon = new Date();
horizon.setDate(horizon.getDate() + days);
const toDate = formatLocalDate(horizon);
const toDate = localDateString(horizon);
// 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.
+99 -5
View File
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { getNextEpisode } from "@sofa/api/utils";
import {
clearAllTables,
insertPlatform,
@@ -157,7 +158,9 @@ describe("getUserStats", () => {
describe("getContinueWatchingFeed", () => {
test("returns in-progress shows with next unwatched episode", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3, {
airDates: ["2026-01-01", "2026-01-08", "2026-01-15"],
});
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
@@ -171,7 +174,9 @@ describe("getContinueWatchingFeed", () => {
test("excludes completed shows", () => {
insertUser();
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
const { titleId } = insertTvShow("tv-1", 99999, 1, 3, {
airDates: ["2026-01-01", "2026-01-08", "2026-01-15"],
});
insertStatus("user-1", titleId, "completed");
const feed = getContinueWatchingFeed("user-1");
@@ -195,8 +200,12 @@ describe("getContinueWatchingFeed", () => {
test("sorts by most recent watch", () => {
insertUser();
const show1 = insertTvShow("tv-1", 11111, 1, 3);
const show2 = insertTvShow("tv-2", 22222, 1, 3);
const show1 = insertTvShow("tv-1", 11111, 1, 3, {
airDates: ["2026-01-01", "2026-01-08", "2026-01-15"],
});
const show2 = insertTvShow("tv-2", 22222, 1, 3, {
airDates: ["2026-01-01", "2026-01-08", "2026-01-15"],
});
insertStatus("user-1", show1.titleId, "in_progress");
insertStatus("user-1", show2.titleId, "in_progress");
@@ -213,7 +222,9 @@ describe("getContinueWatchingFeed", () => {
test("skips show when all episodes are watched (no next episode)", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2);
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2, {
airDates: ["2026-01-01", "2026-01-08"],
});
insertStatus("user-1", titleId, "in_progress");
for (const epId of episodeIds) {
insertEpisodeWatch("user-1", epId);
@@ -222,6 +233,89 @@ describe("getContinueWatchingFeed", () => {
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("excludes a caught-up show whose only unwatched episode has no air date", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3, {
airDates: ["2026-01-01", "2026-01-08"],
});
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
insertEpisodeWatch("user-1", episodeIds[1]);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("counts only aired episodes in totals", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 4, {
airDates: ["2026-01-01", "2026-01-08", "2026-01-15"],
});
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0].totalEpisodes).toBe(3);
expect(feed[0].watchedEpisodes).toBe(1);
expect(feed[0].nextEpisode?.episodeNumber).toBe(2);
});
test("skips future-dated episodes", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2, {
airDates: ["2026-01-01", "2026-12-01"],
});
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
});
// ── getNextEpisode (client mirror) ──────────────────────────────────
describe("getNextEpisode (client mirror)", () => {
test("treats undated episodes as unaired and counts only aired episodes", () => {
const seasons = [
{
seasonNumber: 1,
episodes: [
{
id: "e1",
episodeNumber: 1,
name: "E1",
airDate: "2026-01-01",
stillPath: null,
stillThumbHash: null,
},
{
id: "e2",
episodeNumber: 2,
name: "E2",
airDate: "2026-01-08",
stillPath: null,
stillThumbHash: null,
},
{
id: "e3",
episodeNumber: 3,
name: "E3",
airDate: null,
stillPath: null,
stillThumbHash: null,
},
],
},
] as never;
const result = getNextEpisode(seasons, new Set(["e1"]));
expect(result.nextEpisode?.episodeNumber).toBe(2);
expect(result.totalEpisodes).toBe(2);
expect(result.watchedEpisodes).toBe(1);
});
});
// ── getNewAvailableFeed ─────────────────────────────────────────────
+3 -1
View File
@@ -1,5 +1,7 @@
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { localDateString } from "@sofa/config";
import { db } from "../client";
import {
episodes,
@@ -82,7 +84,7 @@ export function getUserStatusCounts(userId: string) {
.all();
// Count TV shows where all aired episodes are watched (caught_up + completed display states)
const today = new Date().toISOString().slice(0, 10);
const today = localDateString();
const [tvRow] = db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
+3 -1
View File
@@ -1,5 +1,7 @@
import { and, asc, countDistinct, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
import { localDateString } from "@sofa/config";
import { db } from "../client";
import {
episodes,
@@ -51,7 +53,7 @@ function watchedEpisodeCount(
}
function displayStatusExpr() {
const today = new Date().toISOString().slice(0, 10);
const today = localDateString();
const aired = airedEpisodeCount(titles.id, today);
const watched = watchedEpisodeCount(titles.id, userTitleStatus.userId, today);
+3 -1
View File
@@ -1,5 +1,7 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { localDateString } from "@sofa/config";
import { db } from "../client";
import {
episodes,
@@ -226,7 +228,7 @@ export function getUserStatusesByTitleIds(userId: string, titleIds: string[]) {
export function getEpisodeProgressByTitleIds(userId: string, titleIds: string[]) {
if (titleIds.length === 0) return [];
const today = new Date().toISOString().slice(0, 10);
const today = localDateString();
return db
.select({
titleId: titles.id,