Files
sofa/lib/services/tracking.ts
T
jakeandClaude Opus 4.6 0aac1d0d37 Add unwatch support for episodes/seasons, fix nested button hydration error
Toggle episode watched state, bulk unwatch seasons via new DELETE endpoints,
and replace outer <button> with <div role="button"> in season accordion to
avoid invalid nested button HTML that caused hydration errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:15:44 -05:00

343 lines
7.8 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 function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
) {
const now = new Date();
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status, updatedAt: now },
})
.run();
if (status === "completed") {
markAllEpisodesWatched(userId, titleId);
}
}
export function removeTitleStatus(userId: string, titleId: string) {
db.delete(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.run();
}
export function logMovieWatch(userId: string, titleId: string) {
const now = new Date();
db.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source: "manual" })
.run();
// Auto-set status to completed
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing) {
setTitleStatus(userId, titleId, "completed");
} else if (existing.status !== "completed") {
setTitleStatus(userId, titleId, "completed");
}
}
export function logEpisodeWatch(userId: string, episodeId: string) {
const now = new Date();
db.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source: "manual" })
.run();
// Find the title for this episode
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
if (!ep) return;
const season = 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 = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing) {
setTitleStatus(userId, titleId, "in_progress");
}
// Check if all episodes are watched -> auto-complete
checkAllEpisodesWatched(userId, titleId);
}
function markAllEpisodesWatched(userId: string, titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return;
const now = new Date();
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const existing = db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (!existing) {
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: now,
source: "manual",
})
.run();
}
}
}
}
function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
let totalEpisodes = 0;
let watchedEpisodes = 0;
for (const s of allSeasons) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (watch) watchedEpisodes++;
}
}
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
setTitleStatus(userId, titleId, "completed");
}
}
export function unwatchEpisode(userId: string, episodeId: string) {
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 = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
if (!ep) return;
const season = db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
.get();
if (!season) return;
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId),
),
)
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
}
}
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all();
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
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 = db
.select()
.from(seasons)
.where(eq(seasons.id, seasonId))
.get();
if (!season) return;
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId),
),
)
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
}
}
export function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
) {
const now = new Date();
if (ratingStars === 0) {
db.delete(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.run();
return;
}
db.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
set: { ratingStars, ratedAt: now },
})
.run();
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
const rating = db
.select()
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.get();
// Get watched episode IDs for this title
const titleSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
const watchedEpisodeIds: string[] = [];
for (const s of titleSeasons) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const watch = 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,
};
}