mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
7 files changed
+163
-43
No files matched your search
+21
-14
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user