Files
sofa/lib/services/tracking.ts
T
jakeandClaude Opus 4.6 a940e61d75 Derive TV show status from episode watch data instead of manual selection
Status (watchlist/watching/completed) for TV shows is now automatically
determined by episode progress rather than requiring manual selection from
a dropdown. The only manual action is adding/removing from watchlist —
watching and completed states flow from episode data.

- Decouple markAllEpisodesWatched from setTitleStatus and export it
- Replace status dropdown with toggle button + read-only status badge
- Add "Mark All Watched" button with confirmation dialog for TV shows
- Fix optimistic transitions (watchlist → watching on first episode)
- Simplify W keyboard shortcut to toggle watchlist on/off
- Add optimistic completion check when marking entire seasons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:14:21 -05:00

376 lines
8.5 KiB
TypeScript

import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
episodes,
seasons,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@/lib/db/schema";
export async function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status, updatedAt: now },
})
.run();
}
export async function removeTitleStatus(userId: string, titleId: string) {
await db
.delete(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.run();
}
export async function logMovieWatch(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source })
.run();
// Auto-set status to completed
const existing = await db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing) {
await setTitleStatus(userId, titleId, "completed", source);
} else if (existing.status !== "completed") {
await setTitleStatus(userId, titleId, "completed", source);
}
}
export async function logEpisodeWatch(
userId: string,
episodeId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source })
.run();
// Find the title for this episode
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
if (!ep) return;
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
.get();
if (!season) return;
const titleId = season.titleId;
// Auto-set status to in_progress if not set
const existing = await db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing) {
await setTitleStatus(userId, titleId, "in_progress", source);
}
// Check if all episodes are watched -> auto-complete
await checkAllEpisodesWatched(userId, titleId);
}
export async function markAllEpisodesWatched(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title || title.type !== "tv") return;
const now = new Date();
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const existing = await db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (!existing) {
await db
.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: now,
source,
})
.run();
}
}
}
await setTitleStatus(userId, titleId, "completed", source);
}
async function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
let totalEpisodes = 0;
let watchedEpisodes = 0;
for (const s of allSeasons) {
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = await db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (watch) watchedEpisodes++;
}
}
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
await setTitleStatus(userId, titleId, "completed");
}
}
export async function unwatchEpisode(userId: string, episodeId: string) {
await db
.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
),
)
.run();
// Find parent title and downgrade from completed to in_progress
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
if (!ep) return;
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
.get();
if (!season) return;
const existing = await db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId),
),
)
.get();
if (existing?.status === "completed") {
await setTitleStatus(userId, season.titleId, "in_progress");
}
}
export async function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all();
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
await db
.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.run();
}
// Find parent title and downgrade from completed to in_progress
const season = await db
.select()
.from(seasons)
.where(eq(seasons.id, seasonId))
.get();
if (!season) return;
const existing = await db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId),
),
)
.get();
if (existing?.status === "completed") {
await setTitleStatus(userId, season.titleId, "in_progress");
}
}
export async function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
) {
const now = new Date();
if (ratingStars === 0) {
await db
.delete(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.run();
return;
}
await db
.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
set: { ratingStars, ratedAt: now },
})
.run();
}
export async function getUserTitleInfo(userId: string, titleId: string) {
const status = await db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
const rating = await db
.select()
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.get();
// Get watched episode IDs for this title
const titleSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
const watchedEpisodeIds: string[] = [];
for (const s of titleSeasons) {
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const watch = await db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (watch) watchedEpisodeIds.push(ep.id);
}
}
return {
status: status?.status ?? null,
rating: rating?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds,
};
}