refactor: consolidate watchlist states (#18)

- Rename `watchlist` → `in_watchlist` and `in_progress` → `watching` across the full stack (DB migration, Drizzle schema, core services, API contract, web, native)
- Add a new `caught_up` status for TV shows where all aired episodes are watched but the show is still airing
- Add `titles.watchAll` procedure and `markAllWatched` action to mark every episode of a TV show as watched in one step; replace the old "Mark as Watching" / "Mark as Completed" context-menu actions with "Mark All Watched" (TV) and "Mark as Watched" (movie)
- Extract `display-status.ts` in `@sofa/api` to share status → display label/color logic between web and native
- Add `getDisplayStatusesByTitleIds` to `@sofa/core/tracking` and wire it into the dashboard library feed so clients receive resolved display statuses
- Show a destructive Alert confirmation before removing a title from the library (native)
- Remove "Mark as Completed" from the continue-watching card context menu
- Update i18n catalogs for all 6 locales (de, en, es, fr, it, pt)
This commit is contained in:
2026-03-20 15:31:31 -04:00
committed by GitHub
parent 4b4d35ce31
commit 5c70d29fac
49 changed files with 4889 additions and 1309 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import {
getAllTrackedTitleIds,
getCompletedTitleIds,
getEngagedTitleIds,
getEpisodesBySeasonIds,
getEpisodeWatchCountSince,
getEpisodeWatchesByEpisodeIds,
@@ -299,7 +299,7 @@ export { getLibraryFeed } from "@sofa/db/queries/discovery";
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = getCompletedTitleIds(userId);
const userCompletedOrRated = getEngagedTitleIds(userId);
const ratedIds = getHighlyRatedTitleIds(userId);
+5 -1
View File
@@ -48,7 +48,11 @@ export async function getSonarrList(
userId: string,
statuses: Status[] = ["watchlist"],
): Promise<{ TvdbId: number; Title: string }[]> {
const rows = getSonarrShows(userId, statuses);
// TV never stores 'completed' — map it to 'in_progress' (completion is derived)
const mappedStatuses = [
...new Set(statuses.map((s) => (s === "completed" ? "in_progress" : s))),
] as Status[];
const rows = getSonarrShows(userId, mappedStatuses);
// Resolve missing TVDB IDs in parallel instead of sequentially
const needsResolution = rows.filter((r) => r.tvdbId == null);
+59 -25
View File
@@ -1,3 +1,6 @@
import type { DisplayStatus } from "@sofa/api/display-status";
import { getDisplayStatus } from "@sofa/api/display-status";
import { getTitlesByIds } from "@sofa/db/queries/discovery";
import { getTitleById } from "@sofa/db/queries/title";
import {
batchInsertEpisodeWatchesTransaction,
@@ -64,19 +67,16 @@ export function logEpisodeWatch(
const now = watchedAt ?? new Date();
insertEpisodeWatch(userId, episodeId, now, source);
// Find the title for this episode (single JOIN instead of 2 queries)
// Find the title for this episode
const titleId = getEpisodeTitleId(episodeId);
if (!titleId) return;
// Auto-set status to in_progress if not set
// Auto-set status to in_progress if not set or still on watchlist
const existing = getTitleStatus(userId, titleId);
if (!existing || existing.status === "watchlist") {
setTitleStatus(userId, titleId, "in_progress", source);
}
// Check if all episodes are watched -> auto-complete
checkAllEpisodesWatched(userId, titleId);
}
export function logEpisodeWatchBatch(
@@ -104,33 +104,24 @@ export function markAllEpisodesWatched(
batchInsertMissingEpisodeWatches(userId, epIds, existingWatches, source, now);
setTitleStatus(userId, titleId, "completed", source);
}
function checkAllEpisodesWatched(userId: string, titleId: string) {
const epIds = getAllEpisodeIdsForTitle(titleId);
const totalEpisodes = epIds.length;
if (totalEpisodes === 0) return;
const watchCount = countDistinctEpisodeWatches(userId, epIds);
if (watchCount >= totalEpisodes) {
setTitleStatus(userId, titleId, "completed");
}
// TV never stores 'completed' — set in_progress and let display status derive the rest
setTitleStatus(userId, titleId, "in_progress", source);
}
export function unwatchEpisode(userId: string, episodeId: string) {
deleteEpisodeWatch(userId, episodeId);
// Find parent title and downgrade from completed to in_progress
const titleId = getEpisodeTitleId(episodeId);
if (!titleId) return;
const existing = getTitleStatus(userId, titleId);
if (!existing || existing.status !== "in_progress") return;
if (existing?.status === "completed") {
setTitleStatus(userId, titleId, "in_progress");
// If no episodes remain watched, downgrade to watchlist
const epIds = getAllEpisodeIdsForTitle(titleId);
const watchCount = countDistinctEpisodeWatches(userId, epIds);
if (watchCount === 0) {
setTitleStatus(userId, titleId, "watchlist");
}
}
@@ -142,14 +133,17 @@ export function unwatchSeason(userId: string, seasonId: string) {
deleteEpisodeWatches(userId, epIds);
}
// Find parent title and downgrade from completed to in_progress
const season = getSeasonById(seasonId);
if (!season) return;
const existing = getTitleStatus(userId, season.titleId);
if (!existing || existing.status !== "in_progress") return;
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
// If no episodes remain watched, downgrade to watchlist
const allEpIds = getAllEpisodeIdsForTitle(season.titleId);
const watchCount = countDistinctEpisodeWatches(userId, allEpIds);
if (watchCount === 0) {
setTitleStatus(userId, season.titleId, "watchlist");
}
}
@@ -182,6 +176,46 @@ export function getUserStatusesByTitleIds(
return result;
}
/**
* Derive display statuses for a set of titles.
* Resolves stored status + episode progress + TMDB show status into display status.
*/
export function getDisplayStatusesByTitleIds(
userId: string,
titleIds: string[],
): Record<string, DisplayStatus> {
if (titleIds.length === 0) return {};
const storedStatuses = getUserStatusesByTitleIds(userId, titleIds);
const statusEntries = Object.entries(storedStatuses);
if (statusEntries.length === 0) return {};
// Find TV titles with in_progress status that need episode progress resolution
const tvInProgressIds = statusEntries
.filter(([, status]) => status === "in_progress")
.map(([id]) => id);
// Fetch title data for type + TMDB status
const trackedIds = statusEntries.map(([id]) => id);
const titles = getTitlesByIds(trackedIds);
const titleMap = new Map(titles.map((t) => [t.id, t]));
// Fetch episode progress for TV in_progress titles
const episodeProgress =
tvInProgressIds.length > 0 ? getEpisodeProgressByTitleIds(userId, tvInProgressIds) : {};
const result: Record<string, DisplayStatus> = {};
for (const [titleId, storedStatus] of statusEntries) {
const title = titleMap.get(titleId);
const titleType = (title?.type ?? "movie") as "movie" | "tv";
const tmdbStatus = title?.status ?? null;
const progress = episodeProgress[titleId] ?? null;
result[titleId] = getDisplayStatus(storedStatus, titleType, tmdbStatus, progress);
}
return result;
}
export function getEpisodeProgressByTitleIds(
userId: string,
titleIds: string[],