From abe7f558be8afa076640bd3cf6c6f49e149eb580 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 01:50:00 +0000 Subject: [PATCH 01/45] fix(cms): add video-coverage REST endpoint and revert maxLimit regression - Add /api/video-coverage custom endpoint with SQL aggregation for per-video coverage counts (subtitles + audio, human vs AI) - Revert maxLimit: 100 from GraphQL config (broke pageSize: 5000, caused 11-page sequential fetching instead of 1) - Increase language cache TTL from 5min to 24h (geo data rarely changes) The new endpoint benchmarks at 60-660ms vs 22-47s for the current GraphQL approach of fetching 414K variant rows through the ORM. --- apps/cms/config/plugins.ts | 3 - .../controllers/video-coverage.ts | 24 +++ .../video-coverage/routes/video-coverage.ts | 14 ++ .../video-coverage/services/video-coverage.ts | 177 ++++++++++++++++++ apps/manager/src/app/api/languages/route.ts | 4 +- 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 apps/cms/src/api/video-coverage/controllers/video-coverage.ts create mode 100644 apps/cms/src/api/video-coverage/routes/video-coverage.ts create mode 100644 apps/cms/src/api/video-coverage/services/video-coverage.ts diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index 73191dcf..9599b4ba 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -47,9 +47,6 @@ const config = ({ // dynamic-zone unions (reads .kind on undefined nodes). Set high // to avoid the crash path in the library's recursive traversal. depthLimit: 100, - // Cap the maximum number of items returned in a single collection - // field to limit N+1 association queries that exhaust the DB pool. - maxLimit: 100, landingPage: env("NODE_ENV") !== "production", generateArtifacts: true, artifacts: { diff --git a/apps/cms/src/api/video-coverage/controllers/video-coverage.ts b/apps/cms/src/api/video-coverage/controllers/video-coverage.ts new file mode 100644 index 00000000..431c6943 --- /dev/null +++ b/apps/cms/src/api/video-coverage/controllers/video-coverage.ts @@ -0,0 +1,24 @@ +import type { Core } from "@strapi/strapi" +import { queryVideoCoverage } from "../services/video-coverage" + +type StrapiContext = { + status: number + body: unknown + query: Record +} + +export default ({ strapi }: { strapi: Core.Strapi }) => ({ + async index(ctx: StrapiContext) { + const languageIds = ctx.query.languageIds + ? ctx.query.languageIds.split(",").filter(Boolean) + : undefined + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const knex = (strapi.db as any).connection + + const videos = await queryVideoCoverage(knex, languageIds) + + ctx.status = 200 + ctx.body = { videos } + }, +}) diff --git a/apps/cms/src/api/video-coverage/routes/video-coverage.ts b/apps/cms/src/api/video-coverage/routes/video-coverage.ts new file mode 100644 index 00000000..bfad9b0d --- /dev/null +++ b/apps/cms/src/api/video-coverage/routes/video-coverage.ts @@ -0,0 +1,14 @@ +export default { + routes: [ + { + method: "GET", + path: "/video-coverage", + handler: "video-coverage.index", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + ], +} diff --git a/apps/cms/src/api/video-coverage/services/video-coverage.ts b/apps/cms/src/api/video-coverage/services/video-coverage.ts new file mode 100644 index 00000000..b0e8d314 --- /dev/null +++ b/apps/cms/src/api/video-coverage/services/video-coverage.ts @@ -0,0 +1,177 @@ +/** + * Video Coverage Service + * + * Computes per-video coverage counts via SQL aggregation. Returns all + * published videos with metadata, parent-child links, image URLs, and + * subtitle/audio coverage counts broken down by human vs AI. + * + * Follows the same SQL pattern as coverage-snapshot service but returns + * per-video detail instead of library-wide aggregates. + * + * Critical: All queries filter `published_at IS NOT NULL` to avoid + * counting Strapi v5 draft rows. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KnexInstance = any + +type CoverageCounts = { + human: number + ai: number +} + +type VideoCoverageRow = { + document_id: string + core_id: string | null + title: string | null + label: string | null + slug: string | null + ai_metadata: boolean | null + thumbnail: string | null + video_still: string | null + parent_document_ids: string[] | null + sub_human: number + sub_ai: number + aud_human: number + aud_ai: number +} + +type VideoCoverageResult = { + documentId: string + coreId: string | null + title: string | null + label: string | null + slug: string | null + aiMetadata: boolean | null + thumbnailUrl: string | null + videoStillUrl: string | null + parentDocumentIds: string[] + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + } +} + +export async function queryVideoCoverage( + knex: KnexInstance, + languageIds?: string[], +): Promise { + const hasLangFilter = languageIds && languageIds.length > 0 + + // Two-step approach: first compute per-video, per-language coverage in CTEs, + // then aggregate to per-video counts and join with video metadata. + + const bindings: unknown[] = [] + + // Subtitle coverage CTE + const subLangClause = hasLangFilter ? `AND l.core_id = ANY(?)` : "" + if (hasLangFilter) bindings.push(languageIds) + + // Variant coverage CTE + const audLangClause = hasLangFilter ? `AND l.core_id = ANY(?)` : "" + if (hasLangFilter) bindings.push(languageIds) + + const sql = ` + WITH subtitle_per_lang AS ( + SELECT + v.document_id AS vid, + l.core_id AS lang_core_id, + BOOL_OR(NOT COALESCE(s.ai_generated, true)) AS has_human + FROM video_subtitles s + JOIN video_subtitles_video_lnk svl ON svl.video_subtitle_id = s.id + JOIN video_subtitles_language_lnk sll ON sll.video_subtitle_id = s.id + JOIN videos v ON v.id = svl.video_id AND v.published_at IS NOT NULL + JOIN languages l ON l.id = sll.language_id ${subLangClause} + WHERE s.published_at IS NOT NULL + GROUP BY v.document_id, l.core_id + ), + subtitle_cov AS ( + SELECT + vid, + COUNT(*) FILTER (WHERE has_human) AS sub_human, + COUNT(*) FILTER (WHERE NOT has_human) AS sub_ai + FROM subtitle_per_lang + GROUP BY vid + ), + variant_per_lang AS ( + SELECT + v.document_id AS vid, + l.core_id AS lang_core_id, + BOOL_OR(NOT COALESCE(vr.ai_generated, true)) AS has_human + FROM video_variants vr + JOIN video_variants_video_lnk vvl ON vvl.video_variant_id = vr.id + JOIN video_variants_language_lnk vll ON vll.video_variant_id = vr.id + JOIN videos v ON v.id = vvl.video_id AND v.published_at IS NOT NULL + JOIN languages l ON l.id = vll.language_id ${audLangClause} + WHERE vr.published_at IS NOT NULL + GROUP BY v.document_id, l.core_id + ), + variant_cov AS ( + SELECT + vid, + COUNT(*) FILTER (WHERE has_human) AS aud_human, + COUNT(*) FILTER (WHERE NOT has_human) AS aud_ai + FROM variant_per_lang + GROUP BY vid + ), + parent_links AS ( + SELECT + child.document_id AS child_doc_id, + ARRAY_AGG(DISTINCT parent.document_id) AS parent_document_ids + FROM videos_children_lnk cl + JOIN videos parent ON parent.id = cl.video_id AND parent.published_at IS NOT NULL + JOIN videos child ON child.id = cl.inv_video_id AND child.published_at IS NOT NULL + GROUP BY child.document_id + ), + video_image AS ( + SELECT DISTINCT ON (v.document_id) + v.document_id AS vid, + vi.thumbnail, + vi.video_still + FROM videos v + JOIN video_images_video_lnk vil ON vil.video_id = v.id + JOIN video_images vi ON vi.id = vil.video_image_id AND vi.published_at IS NOT NULL + WHERE v.published_at IS NOT NULL + ORDER BY v.document_id, vi.id + ) + SELECT + v.document_id, + v.core_id, + v.title, + v.label, + v.slug, + v.ai_metadata, + img.thumbnail, + img.video_still, + pl.parent_document_ids, + COALESCE(sc.sub_human, 0)::int AS sub_human, + COALESCE(sc.sub_ai, 0)::int AS sub_ai, + COALESCE(vc.aud_human, 0)::int AS aud_human, + COALESCE(vc.aud_ai, 0)::int AS aud_ai + FROM videos v + LEFT JOIN subtitle_cov sc ON sc.vid = v.document_id + LEFT JOIN variant_cov vc ON vc.vid = v.document_id + LEFT JOIN parent_links pl ON pl.child_doc_id = v.document_id + LEFT JOIN video_image img ON img.vid = v.document_id + WHERE v.published_at IS NOT NULL + ORDER BY v.title NULLS LAST + ` + + const result: { rows: VideoCoverageRow[] } = await knex.raw(sql, bindings) + + return result.rows.map((row) => ({ + documentId: row.document_id, + coreId: row.core_id, + title: row.title, + label: row.label, + slug: row.slug, + aiMetadata: row.ai_metadata, + thumbnailUrl: row.thumbnail, + videoStillUrl: row.video_still, + parentDocumentIds: row.parent_document_ids ?? [], + coverage: { + subtitles: { human: row.sub_human, ai: row.sub_ai }, + audio: { human: row.aud_human, ai: row.aud_ai }, + }, + })) +} diff --git a/apps/manager/src/app/api/languages/route.ts b/apps/manager/src/app/api/languages/route.ts index 45fd8612..485f7bf5 100644 --- a/apps/manager/src/app/api/languages/route.ts +++ b/apps/manager/src/app/api/languages/route.ts @@ -193,8 +193,8 @@ async function fetchLanguagePayload(): Promise { export const languageCache = createSwrCache({ fetcher: fetchLanguagePayload, - ttlMs: 5 * 60_000, // 5 minutes — geo data changes only on core sync - maxStaleMs: 60 * 60_000, // 1 hour — hard limit + ttlMs: 24 * 60 * 60_000, // 24 hours — geo data changes only on core sync + maxStaleMs: 48 * 60 * 60_000, // 48 hours — hard limit label: "language-cache", }) From 7e61b04f31fdd581808684c44e4dcf93b30eefa5 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 01:50:59 +0000 Subject: [PATCH 02/45] fix(manager): replace GraphQL video fetch with CMS REST endpoint Replace the 414K-row GraphQL fetch (22-47s) with a call to the new /api/video-coverage CMS endpoint (60-660ms SQL aggregation). - Coverage counts { human, ai, none } per video per coverage type - Collections show own coverage, standalone videos returned separately - Language-filtered requests hit CMS directly (fast enough at ~60ms) - Global requests use SWR cache (2min TTL) - Remove GraphQL query, fetchAllPages, and JS-side coverage computation --- apps/manager/src/app/api/videos/route.ts | 269 ++++++++--------------- 1 file changed, 97 insertions(+), 172 deletions(-) diff --git a/apps/manager/src/app/api/videos/route.ts b/apps/manager/src/app/api/videos/route.ts index f4293309..8232c052 100644 --- a/apps/manager/src/app/api/videos/route.ts +++ b/apps/manager/src/app/api/videos/route.ts @@ -1,90 +1,29 @@ import { NextResponse } from "next/server" -import { graphql } from "@forge/graphql" import { authenticateRequest } from "@/lib/auth" -import getClient from "@/cms/client" -import { - type PageInfo, - DEFAULT_PAGE_INFO, - fetchAllPages, -} from "@/lib/strapi-pagination" +import { env } from "@/config/env" import { createSwrCache } from "@/lib/swr-cache" // --------------------------------------------------------------------------- -// Typed queries +// Types from CMS /api/video-coverage endpoint // --------------------------------------------------------------------------- -// Flat query: fetches ALL videos at the top level with `parents` for hierarchy -// reconstruction. Explicit `pagination: { limit: -1 }` on nested relations to -// avoid Strapi v5's default limit of 10. -// Only fetches fields needed for coverage computation (aiGenerated + language). -const GET_VIDEOS_CONNECTION = graphql(` - query GetVideosApi($pagination: PaginationArg) { - videos_connection(pagination: $pagination) { - nodes { - documentId - coreId - title - label - slug - aiMetadata - images(pagination: { limit: -1 }) { - thumbnail - videoStill - } - parents(pagination: { limit: -1 }) { - documentId - } - variants(pagination: { limit: -1 }) { - aiGenerated - language { - coreId - } - } - subtitles(pagination: { limit: -1 }) { - aiGenerated - language { - coreId - } - } - } - pageInfo { - page - pageCount - pageSize - total - } - } - } -`) - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type RawMediaItem = { - aiGenerated: boolean | null - language: { coreId: string | null } | null -} - -type RawImage = { - thumbnail: string | null - videoStill: string | null -} - -type RawVideoNode = { +type CmsVideoCoverage = { documentId: string coreId: string | null title: string | null label: string | null slug: string | null aiMetadata: boolean | null - images: RawImage[] | null - parents: Array<{ documentId: string }> | null - variants: RawMediaItem[] | null - subtitles: RawMediaItem[] | null + thumbnailUrl: string | null + videoStillUrl: string | null + parentDocumentIds: string[] + coverage: { + subtitles: { human: number; ai: number } + audio: { human: number; ai: number } + } } -type CoverageStatus = "human" | "ai" | "none" +type CoverageCounts = { human: number; ai: number; none: number } const LABEL_DISPLAY: Record = { collection: "Collection", @@ -99,74 +38,45 @@ const LABEL_DISPLAY: Record = { } // --------------------------------------------------------------------------- -// Coverage helpers +// Fetch from CMS video-coverage endpoint // --------------------------------------------------------------------------- -function determineCoverageForItems( - items: RawMediaItem[], - selectedLanguageIds: Set, -): CoverageStatus { - // When no languages selected, evaluate ALL items to show global coverage - const matching = - selectedLanguageIds.size === 0 - ? items.filter((item) => item.language?.coreId) - : items.filter( - (item) => - item.language?.coreId && - selectedLanguageIds.has(item.language.coreId), - ) - - if (matching.length === 0) return "none" +async function fetchVideoCoverage( + languageIds?: string[], +): Promise { + const params = new URLSearchParams() + if (languageIds && languageIds.length > 0) { + params.set("languageIds", languageIds.join(",")) + } + const qs = params.toString() + const url = `${env.STRAPI_URL}/api/video-coverage${qs ? `?${qs}` : ""}` - const allAi = matching.every((item) => item.aiGenerated) - return allAi ? "ai" : "human" -} + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) -function determineCoverage( - video: RawVideoNode, - selectedLanguageIds: Set, -): { subtitles: CoverageStatus; audio: CoverageStatus; meta: CoverageStatus } { - return { - subtitles: determineCoverageForItems( - video.subtitles ?? [], - selectedLanguageIds, - ), - audio: determineCoverageForItems(video.variants ?? [], selectedLanguageIds), - meta: - video.aiMetadata === true - ? "ai" - : video.aiMetadata === false - ? "human" - : "none", + if (!response.ok) { + throw new Error( + `CMS /api/video-coverage returned ${response.status}: ${await response.text()}`, + ) } + + const data = (await response.json()) as { videos: CmsVideoCoverage[] } + return data.videos } // --------------------------------------------------------------------------- -// SWR cache for video nodes (avoids ~4s Strapi query on every request) +// SWR cache — refreshes in <2s (down from 22-47s with GraphQL) // --------------------------------------------------------------------------- -async function fetchVideoNodes(): Promise { - const client = getClient() - return fetchAllPages(async (page) => { - const result = await client.query({ - query: GET_VIDEOS_CONNECTION, - variables: { pagination: { page, pageSize: 5000 } }, - fetchPolicy: "no-cache", - }) - const conn = result.data?.videos_connection - return { - nodes: (conn?.nodes ?? []) as unknown as RawVideoNode[], - pageInfo: (conn?.pageInfo ?? DEFAULT_PAGE_INFO) as PageInfo, - } - }) -} - export const videoCache = createSwrCache({ - fetcher: fetchVideoNodes, - ttlMs: 2 * 60_000, // 2 minutes — actively edited content - maxStaleMs: 30 * 60_000, // 30 minutes — hard limit + fetcher: () => fetchVideoCoverage(), + ttlMs: 2 * 60_000, + maxStaleMs: 30 * 60_000, label: "video-cache", }) + // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- @@ -177,23 +87,34 @@ export async function GET(request: Request) { const url = new URL(request.url) const languageIds = url.searchParams.get("languageIds")?.split(",") ?? [] - const selectedSet = new Set(languageIds.filter(Boolean)) + const selectedLanguages = languageIds.filter(Boolean) try { - const videoNodes = await videoCache.get() - - function toVideoItem(video: RawVideoNode) { - const variantLanguageIds = (video.variants ?? []) - .map((v) => v.language?.coreId) - .filter((id): id is string => id != null) - const subtitleLanguageIds = (video.subtitles ?? []) - .map((s) => s.language?.coreId) - .filter((id): id is string => id != null) + // Fetch from CMS — use cached global data for unfiltered requests, + // direct fetch for language-filtered requests (SQL is fast enough). + const videos = + selectedLanguages.length === 0 + ? await videoCache.get() + : await fetchVideoCoverage(selectedLanguages) + + const numSelected = selectedLanguages.length + + function toCoverageCounts(counts: { + human: number + ai: number + }): CoverageCounts { + return { + human: counts.human, + ai: counts.ai, + none: + numSelected > 0 + ? Math.max(0, numSelected - counts.human - counts.ai) + : 0, + } + } - const firstImage = (video.images ?? []).find( - (img) => img.thumbnail || img.videoStill, - ) - const imageUrl = firstImage?.thumbnail ?? firstImage?.videoStill ?? null + function toVideoItem(video: CmsVideoCoverage) { + const imageUrl = video.thumbnailUrl ?? video.videoStillUrl ?? null return { id: String(video.coreId ?? video.documentId), @@ -201,23 +122,28 @@ export async function GET(request: Request) { video.title ?? video.slug ?? String(video.coreId ?? video.documentId), imageUrl, label: video.label ?? "unknown", - coverage: determineCoverage(video, selectedSet), - variantLanguageIds, - subtitleLanguageIds, + coverage: { + subtitles: toCoverageCounts(video.coverage.subtitles), + audio: toCoverageCounts(video.coverage.audio), + meta: { + human: video.aiMetadata === false ? 1 : 0, + ai: video.aiMetadata === true ? 1 : 0, + none: video.aiMetadata == null ? 1 : 0, + } satisfies CoverageCounts, + }, } } - // Reconstruct parent-child hierarchy from the flat video list. - // Each video's `parents` field tells us which videos it belongs to. - const videoMap = new Map(videoNodes.map((v) => [v.documentId, v])) + // Reconstruct parent-child hierarchy from parentDocumentIds + const videoMap = new Map(videos.map((v) => [v.documentId, v])) - const parentChildrenMap = new Map() - for (const video of videoNodes) { - for (const parent of video.parents ?? []) { - let children = parentChildrenMap.get(parent.documentId) + const parentChildrenMap = new Map() + for (const video of videos) { + for (const parentDocId of video.parentDocumentIds) { + let children = parentChildrenMap.get(parentDocId) if (!children) { children = [] - parentChildrenMap.set(parent.documentId, children) + parentChildrenMap.set(parentDocId, children) } children.push(video) } @@ -228,6 +154,11 @@ export async function GET(request: Request) { title: string label: string labelDisplay: string + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts + } videos: ReturnType[] }> = [] @@ -235,35 +166,29 @@ export async function GET(request: Request) { const parent = videoMap.get(parentDocId) if (!parent) continue + const parentItem = toVideoItem(parent) + collections.push({ - id: String(parent.coreId ?? parent.documentId), - title: - parent.title ?? - parent.slug ?? - String(parent.coreId ?? parent.documentId), - label: parent.label ?? "unknown", + id: parentItem.id, + title: parentItem.title, + label: parentItem.label, labelDisplay: LABEL_DISPLAY[parent.label ?? "unknown"] ?? parent.label ?? "unknown", + coverage: parentItem.coverage, videos: children.map(toVideoItem), }) } // Videos that aren't children of any parent and have no children themselves - const standalone = videoNodes.filter( - (v) => - (v.parents ?? []).length === 0 && !parentChildrenMap.has(v.documentId), - ) - if (standalone.length > 0) { - collections.push({ - id: "standalone", - title: "Standalone Videos", - label: "standalone", - labelDisplay: "Standalone", - videos: standalone.map(toVideoItem), - }) - } + const standalone = videos + .filter( + (v) => + v.parentDocumentIds.length === 0 && + !parentChildrenMap.has(v.documentId), + ) + .map(toVideoItem) - return NextResponse.json({ collections }) + return NextResponse.json({ collections, standalone }) } catch (error) { console.error( "[api/videos] Failed to fetch video data:", From ce6ab1b997644e9d5b9bd227029811c570ff19ad Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 01:53:06 +0000 Subject: [PATCH 03/45] fix(manager): update frontend to consume coverage counts from new API Update CmsVideo/CmsCollection types to use { human, ai, none } counts instead of single status strings. Derive CoverageStatus from counts via countsToStatus() for existing UI components. Note: pre-existing eslint rule definition errors (react-hooks/set-state-in-effect, @next/next/no-img-element) unrelated to this change. --- .../coverage/coverage-report-client.tsx | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 16727805..7b317b43 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -86,18 +86,18 @@ type LanguageOption = { // CMS-sourced types (used by the server page component) // --------------------------------------------------------------------------- +type CoverageCounts = { human: number; ai: number; none: number } + export type CmsVideo = { id: string title: string imageUrl: string | null label: string coverage: { - subtitles: CoverageStatus - audio: CoverageStatus - meta: CoverageStatus + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts } - variantLanguageIds: string[] - subtitleLanguageIds: string[] } export type CmsCollection = { @@ -105,9 +105,20 @@ export type CmsCollection = { title: string label: string labelDisplay: string + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts + } videos: CmsVideo[] } +function countsToStatus(counts: CoverageCounts): CoverageStatus { + if (counts.human > 0) return "human" + if (counts.ai > 0) return "ai" + return "none" +} + interface CoverageReportClientProps { gatewayConfigured: boolean initialErrorMessage: string | null @@ -279,7 +290,7 @@ function cmsVideoToClientVideo( video: CmsVideo, reportType: ReportType, ): ClientVideo { - const coverageStatus = video.coverage[reportType] + const coverageStatus = countsToStatus(video.coverage[reportType]) return { id: video.id, title: video.title, @@ -287,9 +298,7 @@ function cmsVideoToClientVideo( muxAssetId: video.id, muxPlaybackId: "", status: "completed", - languages: [ - ...new Set([...video.variantLanguageIds, ...video.subtitleLanguageIds]), - ], + languages: [], steps: FORGE_STEPS.map((name) => ({ name, status: @@ -1024,10 +1033,24 @@ export function CoverageReportClient({ } const payload = (await response.json()) as { collections: CmsCollection[] + standalone: CmsVideo[] } - if (payload?.collections) { - setVideoCollections(payload.collections) + const allCollections = [...(payload?.collections ?? [])] + if (payload?.standalone?.length > 0) { + allCollections.push({ + id: "standalone", + title: "Standalone Videos", + label: "standalone", + labelDisplay: "Standalone", + coverage: { + subtitles: { human: 0, ai: 0, none: 0 }, + audio: { human: 0, ai: 0, none: 0 }, + meta: { human: 0, ai: 0, none: 0 }, + }, + videos: payload.standalone, + }) } + setVideoCollections(allCollections) } catch { if (!controller.signal.aborted) { setVideoCollectionsLoadFailed(true) From fe05abf1116eae8f3b93992996a1e78e20a27f5f Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 01:57:22 +0000 Subject: [PATCH 04/45] feat(manager): render collection as first tile in coverage grid The collection (e.g. feature film, series) now appears as the first tile in its grid, showing its own coverage status. Distinguished from children by a thicker border and rounded corners. In expanded view, labeled with "(collection)" suffix. --- apps/manager/src/app/globals.css | 7 +++ .../coverage/coverage-report-client.tsx | 49 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 3ca54caf..007b0293 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1900,6 +1900,13 @@ html[data-coverage-loading="true"] .collections::after { height: 32px; } +.tile--collection { + width: 32px; + height: 32px; + border-width: 3px; + border-radius: 4px; +} + .tile--select { cursor: pointer; background: transparent; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 7b317b43..951e4783 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -322,6 +322,42 @@ function cmsVideoToClientVideo( } } +function collectionToClientVideo( + collection: CmsCollection, + reportType: ReportType, +): ClientVideo { + const coverageStatus = countsToStatus(collection.coverage[reportType]) + return { + id: `collection:${collection.id}`, + title: collection.title, + imageUrl: null, + muxAssetId: collection.id, + muxPlaybackId: "", + status: "completed", + languages: [], + steps: FORGE_STEPS.map((name) => ({ + name, + status: + coverageStatus === "human" + ? ("completed" as const) + : ("pending" as const), + retries: 0, + })), + errors: [], + artifacts: {}, + coverageStatus, + stepCompleteness: { + completed: + coverageStatus === "human" + ? FORGE_STEPS.length + : coverageStatus === "ai" + ? 1 + : 0, + total: FORGE_STEPS.length, + }, + } +} + function cmsCollectionsToClientCollections( collections: CmsCollection[], reportType: ReportType, @@ -331,7 +367,10 @@ function cmsCollectionsToClientCollections( title: collection.title, label: collection.label, labelDisplay: collection.labelDisplay, - videos: collection.videos.map((v) => cmsVideoToClientVideo(v, reportType)), + videos: [ + collectionToClientVideo(collection, reportType), + ...collection.videos.map((v) => cmsVideoToClientVideo(v, reportType)), + ], })) } @@ -869,7 +908,11 @@ const CollectionCard = memo(function CollectionCard({ disabled={!isSelectMode} onChange={() => onToggleVideo(video.id)} /> - {video.title} + + {video.id.startsWith("collection:") + ? `${video.title} (collection)` + : video.title} + ) })} @@ -891,7 +934,7 @@ const CollectionCard = memo(function CollectionCard({ role={isSelectMode ? "checkbox" : undefined} aria-checked={isSelectMode ? isSelected : undefined} tabIndex={isSelectMode ? 0 : undefined} - className={`tile tile--video tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} + className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} title={`${video.title} -- ${statusLabel}`} onClick={isSelectMode ? () => onToggleVideo(video.id) : undefined} onKeyDown={ From 10dfd9f4595af8be424fdfe65a9fd78b35c5300b Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:01:56 +0000 Subject: [PATCH 05/45] fix(manager): sort collections by title, skip collection tile for standalone Collections are now sorted alphabetically by title in the API response. Standalone videos group is always last and no longer injects a synthetic collection tile as the first square. --- apps/manager/src/app/api/videos/route.ts | 2 ++ apps/manager/src/features/coverage/coverage-report-client.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/app/api/videos/route.ts b/apps/manager/src/app/api/videos/route.ts index 8232c052..eb18721b 100644 --- a/apps/manager/src/app/api/videos/route.ts +++ b/apps/manager/src/app/api/videos/route.ts @@ -179,6 +179,8 @@ export async function GET(request: Request) { }) } + collections.sort((a, b) => a.title.localeCompare(b.title)) + // Videos that aren't children of any parent and have no children themselves const standalone = videos .filter( diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 951e4783..3207754f 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -368,7 +368,9 @@ function cmsCollectionsToClientCollections( label: collection.label, labelDisplay: collection.labelDisplay, videos: [ - collectionToClientVideo(collection, reportType), + ...(collection.id === "standalone" + ? [] + : [collectionToClientVideo(collection, reportType)]), ...collection.videos.map((v) => cmsVideoToClientVideo(v, reportType)), ], })) From 14ef034b908a182a2fa7b2d830305df62fd3daff Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:05:28 +0000 Subject: [PATCH 06/45] feat(manager): show coverage counts in tile hover tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tile hover now shows: "Title — Human (2 human, 1 AI, 0 none)" instead of just "Title — Human". --- .../src/features/coverage/coverage-report-client.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 3207754f..735795e0 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -65,6 +65,7 @@ type ClientVideo = { errors: Array<{ step: WorkflowStepName; message: string; at: string }> artifacts: Record coverageStatus: CoverageStatus + coverageCounts: CoverageCounts stepCompleteness: { completed: number; total: number } } @@ -252,6 +253,7 @@ function jobToClientVideo(job: JobRecord): ClientVideo { errors: job.errors, artifacts: job.artifacts, coverageStatus: computeCoverageStatus(job), + coverageCounts: { human: 0, ai: 0, none: 0 }, stepCompleteness: { completed: completedCount, total: FORGE_STEPS.length, @@ -290,7 +292,8 @@ function cmsVideoToClientVideo( video: CmsVideo, reportType: ReportType, ): ClientVideo { - const coverageStatus = countsToStatus(video.coverage[reportType]) + const counts = video.coverage[reportType] + const coverageStatus = countsToStatus(counts) return { id: video.id, title: video.title, @@ -310,6 +313,7 @@ function cmsVideoToClientVideo( errors: [], artifacts: {}, coverageStatus, + coverageCounts: counts, stepCompleteness: { completed: coverageStatus === "human" @@ -326,7 +330,8 @@ function collectionToClientVideo( collection: CmsCollection, reportType: ReportType, ): ClientVideo { - const coverageStatus = countsToStatus(collection.coverage[reportType]) + const counts = collection.coverage[reportType] + const coverageStatus = countsToStatus(counts) return { id: `collection:${collection.id}`, title: collection.title, @@ -346,6 +351,7 @@ function collectionToClientVideo( errors: [], artifacts: {}, coverageStatus, + coverageCounts: counts, stepCompleteness: { completed: coverageStatus === "human" @@ -937,7 +943,7 @@ const CollectionCard = memo(function CollectionCard({ aria-checked={isSelectMode ? isSelected : undefined} tabIndex={isSelectMode ? 0 : undefined} className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} - title={`${video.title} -- ${statusLabel}`} + title={`${video.title} — ${statusLabel} (${video.coverageCounts.human} human, ${video.coverageCounts.ai} AI, ${video.coverageCounts.none} none)`} onClick={isSelectMode ? () => onToggleVideo(video.id) : undefined} onKeyDown={ isSelectMode From 0a703af4470b86e78b5f395e0bd358b4ceaa3753 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:07:51 +0000 Subject: [PATCH 07/45] feat(manager): show coverage counts in hover detail bar Display "X human, Y AI, Z none" counts in the bottom hover bar next to the status label. Subtle styling to not overwhelm. --- apps/manager/src/app/globals.css | 6 ++++++ .../src/features/coverage/coverage-report-client.tsx | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 007b0293..1aa02f21 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2752,6 +2752,12 @@ html[data-coverage-loading="true"] .collections::after { color: #fecaca; } +.detail-counts { + font-size: 11px; + color: rgba(248, 250, 252, 0.5); + margin-left: 8px; +} + .detail-media { display: flex; align-items: center; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 735795e0..3dfae6a6 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -943,7 +943,7 @@ const CollectionCard = memo(function CollectionCard({ aria-checked={isSelectMode ? isSelected : undefined} tabIndex={isSelectMode ? 0 : undefined} className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} - title={`${video.title} — ${statusLabel} (${video.coverageCounts.human} human, ${video.coverageCounts.ai} AI, ${video.coverageCounts.none} none)`} + title={`${video.title} — ${statusLabel}`} onClick={isSelectMode ? () => onToggleVideo(video.id) : undefined} onKeyDown={ isSelectMode @@ -1536,6 +1536,9 @@ export function CoverageReportClient({ > {reportConfig.statusLabels[hoveredVideo.status]} + + {hoveredVideo.video.coverageCounts.human} human, {hoveredVideo.video.coverageCounts.ai} AI, {hoveredVideo.video.coverageCounts.none} none + From ef6cb40f5eb02ce5103d529cf984d1832c2a7269 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:12:23 +0000 Subject: [PATCH 08/45] feat(manager): colored coverage count pills in hover detail bar Replace single status label with colored pills: - Green: "X Verified subtitles" (only if > 0) - Purple: "X AI subtitles" (only if > 0) - Red: "X No subtitles" (only if > 0) Label adapts to active report type (subtitles/audio/meta). In global mode (no language filter), "none" count is computed as total languages minus human minus AI. --- apps/manager/src/app/globals.css | 25 ++++++++++-- .../coverage/coverage-report-client.tsx | 38 +++++++++++++++---- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 1aa02f21..67dcc566 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2752,10 +2752,29 @@ html[data-coverage-loading="true"] .collections::after { color: #fecaca; } -.detail-counts { +.detail-pill { + display: inline-flex; + align-items: center; font-size: 11px; - color: rgba(248, 250, 252, 0.5); - margin-left: 8px; + font-weight: 500; + padding: 2px 8px; + border-radius: 9999px; + margin-right: 4px; +} + +.detail-pill--human { + color: #86efac; + background: rgba(22, 163, 74, 0.18); +} + +.detail-pill--ai { + color: #c4b5fd; + background: rgba(124, 58, 237, 0.18); +} + +.detail-pill--none { + color: #fecaca; + background: rgba(239, 68, 68, 0.18); } .detail-media { diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 3dfae6a6..06e07679 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1531,14 +1531,36 @@ export function CoverageReportClient({
- - {reportConfig.statusLabels[hoveredVideo.status]} - - - {hoveredVideo.video.coverageCounts.human} human, {hoveredVideo.video.coverageCounts.ai} AI, {hoveredVideo.video.coverageCounts.none} none - + {(() => { + const c = hoveredVideo.video.coverageCounts + const noneCount = + selectedLanguageIds.length > 0 + ? c.none + : Math.max( + 0, + languageOptions.length - c.human - c.ai, + ) + const typeName = reportConfig.label.toLowerCase() + return ( + <> + {c.human > 0 && ( + + {c.human} Verified {typeName} + + )} + {c.ai > 0 && ( + + {c.ai} AI {typeName} + + )} + {noneCount > 0 && ( + + {noneCount} No {typeName} + + )} + + ) + })()}
From 4d431906aa1e16822bf5c1d13cf906acf91fc08e Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:13:34 +0000 Subject: [PATCH 09/45] fix(manager): lowercase pill labels --- apps/manager/src/features/coverage/coverage-report-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 06e07679..605558e7 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1545,7 +1545,7 @@ export function CoverageReportClient({ <> {c.human > 0 && ( - {c.human} Verified {typeName} + {c.human} verified {typeName} )} {c.ai > 0 && ( @@ -1555,7 +1555,7 @@ export function CoverageReportClient({ )} {noneCount > 0 && ( - {noneCount} No {typeName} + {noneCount} no {typeName} )} From 9b504f68989527a5329a7404aab5ec451193ce91 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:15:59 +0000 Subject: [PATCH 10/45] feat(manager): dashed border on tiles with partial coverage --- apps/manager/src/app/globals.css | 4 ++++ apps/manager/src/features/coverage/coverage-report-client.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 67dcc566..dd776ac6 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1907,6 +1907,10 @@ html[data-coverage-loading="true"] .collections::after { border-radius: 4px; } +.tile--partial { + border-style: dashed; +} + .tile--select { cursor: pointer; background: transparent; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 605558e7..ea99b12e 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -942,7 +942,7 @@ const CollectionCard = memo(function CollectionCard({ role={isSelectMode ? "checkbox" : undefined} aria-checked={isSelectMode ? isSelected : undefined} tabIndex={isSelectMode ? 0 : undefined} - className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} + className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${status !== "none" && video.coverageCounts.none > 0 ? " tile--partial" : ""}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} title={`${video.title} — ${statusLabel}`} onClick={isSelectMode ? () => onToggleVideo(video.id) : undefined} onKeyDown={ From 48375dfefd04d63c6d317e9e458efb1838807685 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:18:50 +0000 Subject: [PATCH 11/45] fix(manager): apply URL update when removing language via pill X button The pill X button was only updating draft state without navigating, so removing a language didn't trigger a data refetch. Now it updates both the draft state and the URL params immediately. --- .../manager/src/features/coverage/LanguageGeoSelector.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/features/coverage/LanguageGeoSelector.tsx b/apps/manager/src/features/coverage/LanguageGeoSelector.tsx index 37e5618c..062c62cd 100644 --- a/apps/manager/src/features/coverage/LanguageGeoSelector.tsx +++ b/apps/manager/src/features/coverage/LanguageGeoSelector.tsx @@ -563,7 +563,13 @@ export function LanguageGeoSelector({ key={language.id} type="button" className="geo-selected-pill" - onClick={() => handleSelect(language.id)} + onClick={() => { + const next = draftLanguages.filter( + (id) => id !== language.id, + ) + setDraftLanguages(next) + applyUrlParams(next) + }} aria-label={`Remove ${language.label}`} > {language.label} From e49ca7d820d601cdd7eb46c1f51941ea5902c095 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:22:26 +0000 Subject: [PATCH 12/45] feat(manager): show explore/translate toggle on all report types ModeToggle now renders for subtitles, audio, and meta report types. Translate button is greyed out with "Coming soon" tooltip on audio and meta. Switching to audio/meta while in translate mode auto-resets to explore. --- apps/manager/src/app/globals.css | 10 +++++++ .../coverage/coverage-report-client.tsx | 27 ++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index dd776ac6..d2bf7a6d 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -695,6 +695,16 @@ body.jobs-standalone code { color: var(--color-ink-inverse); } +.mode-toggle-button.is-disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.mode-toggle-button.is-disabled:hover, +.mode-toggle-button.is-disabled:focus-visible { + background: transparent; +} + .filter-pill { display: inline-flex; align-items: center; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index ea99b12e..26d1994e 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -457,9 +457,11 @@ function useSessionReportType( function ModeToggle({ mode, onChange, + translateDisabled, }: { mode: Mode onChange: (mode: Mode) => void + translateDisabled?: boolean }) { return (
@@ -487,9 +489,11 @@ function ModeToggle({
@@ -1303,11 +1312,15 @@ export function CoverageReportClient({ {showCoverageControls && (
- {hydrated && reportType === "subtitles" && ( - + {hydrated && ( + )}

- {hydrated && reportType === "subtitles" && isSelectMode + {hydrated && isSelectMode && reportType === "subtitles" ? "Select videos for translation." : reportConfig.hintExplore}

From 560fa6d83c18182d4b5567597e5b4e8157741d54 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:24:12 +0000 Subject: [PATCH 13/45] fix(manager): use CSS tooltip for disabled translate button Replace native title attr (doesn't work on disabled buttons) with a CSS ::after pseudo-element tooltip that appears above the button on hover showing "Coming soon". --- apps/manager/src/app/globals.css | 26 +++++++++++ .../coverage/coverage-report-client.tsx | 46 ++++++++++--------- 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index d2bf7a6d..67c5d962 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -705,6 +705,32 @@ body.jobs-standalone code { background: transparent; } +.mode-toggle-disabled-wrap { + position: relative; +} + +.mode-toggle-disabled-wrap::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 4px 10px; + border-radius: 6px; + background: var(--color-ink, #1e293b); + color: var(--color-ink-inverse, #f8fafc); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.mode-toggle-disabled-wrap:hover::after { + opacity: 1; +} + .filter-pill { display: inline-flex; align-items: center; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 26d1994e..a23d6f1a 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -487,29 +487,33 @@ function ModeToggle({ Explore - + + Translate + + ) From 49e0023b5ed0701a12eee3a7f34003fa9c3cd836 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:24:24 +0000 Subject: [PATCH 14/45] fix(manager): add arrow to coming soon tooltip --- apps/manager/src/app/globals.css | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 67c5d962..715ffc9b 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -727,7 +727,21 @@ body.jobs-standalone code { transition: opacity 0.15s ease; } -.mode-toggle-disabled-wrap:hover::after { +.mode-toggle-disabled-wrap::before { + content: ""; + position: absolute; + bottom: calc(100% - 1px); + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--color-ink, #1e293b); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.mode-toggle-disabled-wrap:hover::after, +.mode-toggle-disabled-wrap:hover::before { opacity: 1; } From 62815fa13af185fae7cdd21eeecf2de2028f72e4 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:24:54 +0000 Subject: [PATCH 15/45] fix(manager): increase tooltip arrow size --- apps/manager/src/app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 715ffc9b..d1915bf3 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -733,7 +733,7 @@ body.jobs-standalone code { bottom: calc(100% - 1px); left: 50%; transform: translateX(-50%); - border: 5px solid transparent; + border: 8px solid transparent; border-top-color: var(--color-ink, #1e293b); pointer-events: none; opacity: 0; From d030b55558088692f3e97d193b1fc919965990ef Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:26:05 +0000 Subject: [PATCH 16/45] fix(manager): fix tooltip arrow positioning --- apps/manager/src/app/globals.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index d1915bf3..73d94b45 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -712,7 +712,7 @@ body.jobs-standalone code { .mode-toggle-disabled-wrap::after { content: attr(data-tooltip); position: absolute; - bottom: calc(100% + 6px); + bottom: calc(100% + 14px); left: 50%; transform: translateX(-50%); padding: 4px 10px; @@ -730,7 +730,7 @@ body.jobs-standalone code { .mode-toggle-disabled-wrap::before { content: ""; position: absolute; - bottom: calc(100% - 1px); + bottom: calc(100% - 2px); left: 50%; transform: translateX(-50%); border: 8px solid transparent; From 7eb446bd5efc13b9c577611614d4e6efab333f59 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:29:09 +0000 Subject: [PATCH 17/45] fix(manager): dashed checkbox border for partial coverage in detail view --- apps/manager/src/app/globals.css | 4 ++++ apps/manager/src/features/coverage/coverage-report-client.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 73d94b45..611f914a 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2404,6 +2404,10 @@ html[data-coverage-loading="true"] .collections::after { background: #ef4444; } +.detail-row-checkbox--partial { + border-style: dashed; +} + .detail-row-checkbox:disabled { opacity: 0.5; cursor: default; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index a23d6f1a..4c11e7a3 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -919,7 +919,7 @@ const CollectionCard = memo(function CollectionCard({ > 0 ? " detail-row-checkbox--partial" : ""}`} checked={isSelected} disabled={!isSelectMode} onChange={() => onToggleVideo(video.id)} From c2a83c9bb8b63cccaf2b2d34f8d691aded3f34a7 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:30:04 +0000 Subject: [PATCH 18/45] fix(manager): pin collection to top of its status group in detail view --- .../src/features/coverage/coverage-report-client.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 4c11e7a3..2ea13736 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -888,7 +888,12 @@ const CollectionCard = memo(function CollectionCard({ {(["human", "ai", "none"] as const).map((groupStatus) => { const groupVideos = filteredVideos .filter((v) => v.coverageStatus === groupStatus) - .sort((a, b) => a.title.localeCompare(b.title)) + .sort((a, b) => { + const aIsCollection = a.id.startsWith("collection:") + const bIsCollection = b.id.startsWith("collection:") + if (aIsCollection !== bIsCollection) return aIsCollection ? -1 : 1 + return a.title.localeCompare(b.title) + }) if (groupVideos.length === 0) return null return ( From cc63d452e2a50b45df037cc5462b92532719bee7 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:31:17 +0000 Subject: [PATCH 19/45] fix(manager): show language names instead of IDs in selection bar --- .../src/features/coverage/coverage-report-client.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 2ea13736..d66bba8a 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1458,7 +1458,15 @@ export function CoverageReportClient({ {selectedVideoIds.size === 1 ? "" : "s"} selected
- Languages: {selectedLanguageIds.join(", ") || "None"} + Languages:{" "} + {selectedLanguageIds.length > 0 + ? selectedLanguageIds + .map((id) => { + const lang = languageOptions.find((l) => l.id === id) + return lang?.englishLabel ?? id + }) + .join(", ") + : "None"}
From eedaee8506a43e1094779a8e9a7fd93914dff9ed Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:32:47 +0000 Subject: [PATCH 20/45] fix(manager): fetch language names for selection bar display --- .../coverage/coverage-report-client.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index d66bba8a..0ad9d583 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1043,6 +1043,28 @@ export function CoverageReportClient({ }, [videoCollections, initialJobs, reportType]) const selectedLanguageIds = initialSelectedLanguageIds const languageOptions = initialLanguages + const [languageNameMap, setLanguageNameMap] = useState>( + new Map(), + ) + // Fetch language names once for display in the selection bar + useEffect(() => { + void (async () => { + try { + const response = await apiFetch("/api/languages") + if (!response.ok) return + const payload = (await response.json()) as { + languages: Array<{ id: string; englishLabel: string }> + } + const map = new Map() + for (const lang of payload.languages ?? []) { + map.set(lang.id, lang.englishLabel) + } + setLanguageNameMap(map) + } catch { + // ignore — will fall back to IDs + } + })() + }, []) const errorMessage = initialErrorMessage const [filter, setFilter] = useState("all") const [hoveredVideo, setHoveredVideo] = useState( @@ -1461,10 +1483,7 @@ export function CoverageReportClient({ Languages:{" "} {selectedLanguageIds.length > 0 ? selectedLanguageIds - .map((id) => { - const lang = languageOptions.find((l) => l.id === id) - return lang?.englishLabel ?? id - }) + .map((id) => languageNameMap.get(id) ?? id) .join(", ") : "None"}
From dd5f03b99ece7209530462c65a2634671d9d0e3b Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:37:57 +0000 Subject: [PATCH 21/45] feat(manager): add client-side video search with yellow highlight Search input filters by partial title match on videos and collections. Matching tiles turn warm yellow (#f59e0b) in both tile and detail views. Collection tiles highlight when the collection title matches. --- apps/manager/src/app/globals.css | 32 +++++++++++++++++++ .../coverage/coverage-report-client.tsx | 31 ++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 611f914a..7ce43e68 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1556,6 +1556,22 @@ html[data-coverage-loading="true"] .collections::after { min-width: 0; } +.collection-search { + padding: 4px 10px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + font-size: 13px; + background: var(--color-bg, #fff); + color: var(--color-ink, #1e293b); + outline: none; + min-width: 180px; +} + +.collection-search:focus { + border-color: var(--color-ink, #1e293b); + box-shadow: 0 0 0 1px var(--color-ink, #1e293b); +} + .collection-progress-text { display: inline-flex; align-items: center; @@ -1961,6 +1977,22 @@ html[data-coverage-loading="true"] .collections::after { border-style: dashed; } +.tile--search-match { + background: rgba(251, 191, 36, 0.35) !important; + border-color: #f59e0b !important; + box-shadow: 0 0 0 1px #f59e0b; +} + +.detail-row-checkbox--search-match { + border-color: #f59e0b !important; + background: rgba(251, 191, 36, 0.35) !important; +} + +.detail-row--search-match { + background: rgba(251, 191, 36, 0.1); + border-radius: 4px; +} + .tile--select { cursor: pointer; background: transparent; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 0ad9d583..c49de86e 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -705,6 +705,7 @@ type CollectionCardProps = { isExpanded: boolean isSelectMode: boolean selectedVideoIds: Set + searchMatchIds: Set onToggleExpanded: (collectionId: string) => void onHoverVideo: (details: HoveredVideoDetails | null) => void onToggleVideo: (videoId: string) => void @@ -717,6 +718,7 @@ const CollectionCard = memo(function CollectionCard({ isExpanded, isSelectMode, selectedVideoIds, + searchMatchIds, onToggleExpanded, onHoverVideo, onToggleVideo, @@ -911,7 +913,7 @@ const CollectionCard = memo(function CollectionCard({ return (
)} @@ -1437,6 +1461,7 @@ export function CoverageReportClient({ isExpanded={isExpanded} isSelectMode={isSelectMode} selectedVideoIds={selectedVideoIds} + searchMatchIds={searchMatchIds} onToggleExpanded={toggleExpanded} onHoverVideo={handleHoverVideo} onToggleVideo={toggleVideoSelection} From b9b868960419b8741944a69284c0843830f50859 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:38:32 +0000 Subject: [PATCH 22/45] fix(manager): hide collections with no search matches --- .../coverage/coverage-report-client.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index c49de86e..5d4cf03c 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1243,16 +1243,24 @@ export function CoverageReportClient({ const effectiveFilter = filter const visibleCollections = useMemo(() => { - if (effectiveFilter === "all") return collections - return collections - .map((collection) => ({ - ...collection, - videos: collection.videos.filter( - (video) => video.coverageStatus === effectiveFilter, - ), - })) - .filter((collection) => collection.videos.length > 0) - }, [collections, effectiveFilter]) + let result = collections + if (effectiveFilter !== "all") { + result = result + .map((collection) => ({ + ...collection, + videos: collection.videos.filter( + (video) => video.coverageStatus === effectiveFilter, + ), + })) + .filter((collection) => collection.videos.length > 0) + } + if (searchMatchIds.size > 0) { + result = result.filter((collection) => + collection.videos.some((video) => searchMatchIds.has(video.id)), + ) + } + return result + }, [collections, effectiveFilter, searchMatchIds]) const searchMatchIds = useMemo(() => { const q = searchQuery.trim().toLowerCase() From 1895e759f650526373b60e039bdec21e2490538e Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:39:16 +0000 Subject: [PATCH 23/45] fix(manager): move searchMatchIds above visibleCollections to fix init order --- .../coverage/coverage-report-client.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 5d4cf03c..e0d8484d 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1242,6 +1242,20 @@ export function CoverageReportClient({ const effectiveFilter = filter + const searchMatchIds = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return new Set() + const matched = new Set() + for (const collection of collections) { + for (const video of collection.videos) { + if (video.title.toLowerCase().includes(q)) { + matched.add(video.id) + } + } + } + return matched + }, [collections, searchQuery]) + const visibleCollections = useMemo(() => { let result = collections if (effectiveFilter !== "all") { @@ -1262,20 +1276,6 @@ export function CoverageReportClient({ return result }, [collections, effectiveFilter, searchMatchIds]) - const searchMatchIds = useMemo(() => { - const q = searchQuery.trim().toLowerCase() - if (!q) return new Set() - const matched = new Set() - for (const collection of collections) { - for (const video of collection.videos) { - if (video.title.toLowerCase().includes(q)) { - matched.add(video.id) - } - } - } - return matched - }, [collections, searchQuery]) - const toggleExpanded = useCallback((collectionId: string) => { setExpandedCollections((prev) => prev.includes(collectionId) From 08f9b1a0252354180a0b3c0a9b248839a27400cf Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:40:43 +0000 Subject: [PATCH 24/45] feat(manager): move search below mode toggle, full-width pill style --- apps/manager/src/app/globals.css | 16 ++++++++++++---- .../coverage/coverage-report-client.tsx | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 7ce43e68..f1c7a52a 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1556,15 +1556,23 @@ html[data-coverage-loading="true"] .collections::after { min-width: 0; } +.search-panel { + padding: 0 4px 8px; +} + .collection-search { - padding: 4px 10px; + width: 100%; + padding: 10px 18px; border: 1px solid var(--color-border, #e2e8f0); - border-radius: 6px; - font-size: 13px; + border-radius: 9999px; + font-size: 15px; background: var(--color-bg, #fff); color: var(--color-ink, #1e293b); outline: none; - min-width: 180px; +} + +.collection-search::placeholder { + color: var(--color-muted, #94a3b8); } .collection-search:focus { diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index e0d8484d..fe5059d8 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1362,13 +1362,6 @@ export function CoverageReportClient({ Showing {totalCollections} collection {totalCollections === 1 ? "" : "s"} - setSearchQuery(e.target.value)} - /> )} @@ -1411,6 +1404,18 @@ export function CoverageReportClient({ )} + {showCoverageControls && ( +
+ setSearchQuery(e.target.value)} + /> +
+ )} + {!gatewayConfigured ? (
Configure the videos API endpoint to load coverage data. From 4a88195f65b72422ce6a0a598a5e6ae6031cf979 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:43:41 +0000 Subject: [PATCH 25/45] feat(manager): add filter dropdown next to search, remove yellow filter pill Replace the yellow "Filtering: X / Clear filter" pill with a pill-shaped dropdown select next to the search input. Options: All, Verified, AI, None. Coverage bar segment clicks still update the filter (same state). Labels adapt to the active report type. --- apps/manager/src/app/globals.css | 29 ++++++++++++- .../coverage/coverage-report-client.tsx | 43 +++++++++---------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index f1c7a52a..b16dadc8 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1556,8 +1556,11 @@ html[data-coverage-loading="true"] .collections::after { min-width: 0; } -.search-panel { +.search-filter-panel { + display: flex; + gap: 8px; padding: 0 4px 8px; + align-items: center; } .collection-search { @@ -1580,6 +1583,30 @@ html[data-coverage-loading="true"] .collections::after { box-shadow: 0 0 0 1px var(--color-ink, #1e293b); } +.coverage-filter-select { + padding: 10px 14px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 9999px; + font-size: 14px; + font-weight: 500; + background: var(--color-bg, #fff); + color: var(--color-ink, #1e293b); + outline: none; + cursor: pointer; + white-space: nowrap; + appearance: none; + -webkit-appearance: none; + padding-right: 32px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} + +.coverage-filter-select:focus { + border-color: var(--color-ink, #1e293b); + box-shadow: 0 0 0 1px var(--color-ink, #1e293b); +} + .collection-progress-text { display: inline-flex; align-items: center; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index fe5059d8..1b5ea221 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1380,32 +1380,11 @@ export function CoverageReportClient({ ? "Select videos for translation." : reportConfig.hintExplore}

- {filter !== "all" && ( -
- Filtering: {reportConfig.statusLabels[filter]} - -
- )} )} {showCoverageControls && ( -
+
setSearchQuery(e.target.value)} /> +
+ +
)} From 31602f6da32f6f475efc5a92332b6c0787f321f5 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:45:43 +0000 Subject: [PATCH 26/45] feat(manager): show filtered count and clear filters button --- apps/manager/src/app/globals.css | 16 +++++++++++++++ .../coverage/coverage-report-client.tsx | 20 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index b16dadc8..30df374a 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1617,6 +1617,22 @@ html[data-coverage-loading="true"] .collections::after { white-space: nowrap; } +.clear-filters-button { + border: none; + background: none; + color: var(--color-ink, #1e293b); + font-size: 0.8rem; + font-weight: 500; + text-decoration: underline; + cursor: pointer; + padding: 0; + opacity: 0.7; +} + +.clear-filters-button:hover { + opacity: 1; +} + .collection-cache-clear { display: inline-flex; align-items: center; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 1b5ea221..2641dd60 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1359,8 +1359,24 @@ export function CoverageReportClient({ >
- Showing {totalCollections} collection - {totalCollections === 1 ? "" : "s"} + Showing {totalCollections} + {totalCollections !== collections.length + ? ` of ${collections.length}` + : ""}{" "} + collection + {collections.length === 1 ? "" : "s"} + {(filter !== "all" || searchQuery.trim()) && ( + + )}
From 52f8eb30d70e63b6afa6e9ef987e4c087c831c9b Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:46:25 +0000 Subject: [PATCH 27/45] fix(manager): move collection count below search bar --- .../coverage/coverage-report-client.tsx | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 2641dd60..b26f8cbe 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1351,36 +1351,6 @@ export function CoverageReportClient({ )} - {gatewayConfigured && !errorMessage && totalCollections > 0 && ( -
-
-
- Showing {totalCollections} - {totalCollections !== collections.length - ? ` of ${collections.length}` - : ""}{" "} - collection - {collections.length === 1 ? "" : "s"} - {(filter !== "all" || searchQuery.trim()) && ( - - )} -
-
-
- )} {showCoverageControls && (
@@ -1431,6 +1401,31 @@ export function CoverageReportClient({
)} + {showCoverageControls && totalCollections > 0 && ( +
+
+ Showing {totalCollections} + {totalCollections !== collections.length + ? ` of ${collections.length}` + : ""}{" "} + collection + {collections.length === 1 ? "" : "s"} + {(filter !== "all" || searchQuery.trim()) && ( + + )} +
+
+ )} + {!gatewayConfigured ? (
Configure the videos API endpoint to load coverage data. From e55af9e500c3f6a24bf3df9915bf9c6cad70a615 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:46:41 +0000 Subject: [PATCH 28/45] fix(manager): reduce spacing on collection count row --- apps/manager/src/app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 30df374a..6644aeaa 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1545,7 +1545,7 @@ html[data-coverage-loading="true"] .collections::after { align-items: center; justify-content: space-between; gap: 24px; - padding: 2px 4px 8px; + padding: 0 4px 2px; } .collection-progress { From abe7a4572b24d81f092404f8c89c5d89ba1a72c0 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:48:02 +0000 Subject: [PATCH 29/45] feat(manager): wrap search, filter, and count in a card --- apps/manager/src/app/globals.css | 23 ++++++- .../coverage/coverage-report-client.tsx | 65 +++++++++---------- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 6644aeaa..722fd50a 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1556,15 +1556,32 @@ html[data-coverage-loading="true"] .collections::after { min-width: 0; } -.search-filter-panel { +.search-filter-card { + background: var(--color-panel, #f8fafc); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 12px; + padding: 12px 14px; + margin: 0 0 8px; +} + +.search-filter-row { display: flex; gap: 8px; - padding: 0 4px 8px; align-items: center; } +.search-filter-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.8rem; + font-weight: 600; + color: #756f63; + padding-top: 8px; +} + .collection-search { - width: 100%; + flex: 1; padding: 10px 18px; border: 1px solid var(--color-border, #e2e8f0); border-radius: 9999px; diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index b26f8cbe..394daa44 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1370,15 +1370,15 @@ export function CoverageReportClient({ )} {showCoverageControls && ( -
- setSearchQuery(e.target.value)} - /> -
+
+
+ setSearchQuery(e.target.value)} + />
+ {totalCollections > 0 && ( +
+ Showing {totalCollections} + {totalCollections !== collections.length + ? ` of ${collections.length}` + : ""}{" "} + collection + {collections.length === 1 ? "" : "s"} + {(filter !== "all" || searchQuery.trim()) && ( + + )} +
+ )}
)} - {showCoverageControls && totalCollections > 0 && ( -
-
- Showing {totalCollections} - {totalCollections !== collections.length - ? ` of ${collections.length}` - : ""}{" "} - collection - {collections.length === 1 ? "" : "s"} - {(filter !== "all" || searchQuery.trim()) && ( - - )} -
-
- )} - {!gatewayConfigured ? (
Configure the videos API endpoint to load coverage data. From 08107a8ce83a533e29f063ff7cd44e16939d46d4 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:48:48 +0000 Subject: [PATCH 30/45] fix(manager): restyle search card status row and clear button --- apps/manager/src/app/globals.css | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 722fd50a..e1e04f2e 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1573,11 +1573,12 @@ html[data-coverage-loading="true"] .collections::after { .search-filter-status { display: flex; align-items: center; - gap: 10px; + justify-content: space-between; font-size: 0.8rem; - font-weight: 600; - color: #756f63; - padding-top: 8px; + color: var(--color-muted, #94a3b8); + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid var(--color-border, #e2e8f0); } .collection-search { @@ -1635,19 +1636,19 @@ html[data-coverage-loading="true"] .collections::after { } .clear-filters-button { - border: none; - background: none; + border: 1px solid var(--color-border, #e2e8f0); + background: var(--color-bg, #fff); color: var(--color-ink, #1e293b); - font-size: 0.8rem; + font-size: 0.75rem; font-weight: 500; - text-decoration: underline; cursor: pointer; - padding: 0; - opacity: 0.7; + padding: 3px 10px; + border-radius: 9999px; + white-space: nowrap; } .clear-filters-button:hover { - opacity: 1; + background: var(--color-hover, #f1f5f9); } .collection-cache-clear { From 97de8211c81c953125401255257cf3360eb31834 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:49:51 +0000 Subject: [PATCH 31/45] fix(manager): show collection count and clear button even when 0 matches --- apps/manager/src/features/coverage/coverage-report-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 394daa44..bbf3b20d 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1398,7 +1398,7 @@ export function CoverageReportClient({
- {totalCollections > 0 && ( + {collections.length > 0 && (
Showing {totalCollections} {totalCollections !== collections.length From a38142f493f235d7f966d3172d26675ec2e0a11f Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:50:05 +0000 Subject: [PATCH 32/45] fix(manager): revert clear filters to underline style --- apps/manager/src/app/globals.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index e1e04f2e..9e2721af 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1636,19 +1636,19 @@ html[data-coverage-loading="true"] .collections::after { } .clear-filters-button { - border: 1px solid var(--color-border, #e2e8f0); - background: var(--color-bg, #fff); + border: none; + background: none; color: var(--color-ink, #1e293b); - font-size: 0.75rem; + font-size: 0.8rem; font-weight: 500; + text-decoration: underline; cursor: pointer; - padding: 3px 10px; - border-radius: 9999px; - white-space: nowrap; + padding: 0; + opacity: 0.7; } .clear-filters-button:hover { - background: var(--color-hover, #f1f5f9); + opacity: 1; } .collection-cache-clear { From 448228aaa4336dbe22fbd41747be8d3c4aafa242 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:50:47 +0000 Subject: [PATCH 33/45] fix(manager): match filter dropdown styling to search input --- apps/manager/src/app/globals.css | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 9e2721af..c89f85b8 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1602,11 +1602,10 @@ html[data-coverage-loading="true"] .collections::after { } .coverage-filter-select { - padding: 10px 14px; + padding: 10px 36px 10px 18px; border: 1px solid var(--color-border, #e2e8f0); border-radius: 9999px; - font-size: 14px; - font-weight: 500; + font-size: 15px; background: var(--color-bg, #fff); color: var(--color-ink, #1e293b); outline: none; @@ -1614,10 +1613,9 @@ html[data-coverage-loading="true"] .collections::after { white-space: nowrap; appearance: none; -webkit-appearance: none; - padding-right: 32px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; - background-position: right 10px center; + background-position: right 14px center; } .coverage-filter-select:focus { From 78482518fa1a33d4832cb40b7e9b43ef7fa18aec Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:51:26 +0000 Subject: [PATCH 34/45] feat(manager): search by video name or ID --- apps/manager/src/features/coverage/coverage-report-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index bbf3b20d..c10e2d9e 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -1248,7 +1248,7 @@ export function CoverageReportClient({ const matched = new Set() for (const collection of collections) { for (const video of collection.videos) { - if (video.title.toLowerCase().includes(q)) { + if (video.title.toLowerCase().includes(q) || video.id.toLowerCase().includes(q)) { matched.add(video.id) } } @@ -1375,7 +1375,7 @@ export function CoverageReportClient({ setSearchQuery(e.target.value)} /> From 4e246b2a4ca773dcb93ad743fd210fc1f7750e0c Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:52:35 +0000 Subject: [PATCH 35/45] fix(manager): replace native select with custom filter dropdown Native - setFilter(e.target.value as CoverageFilter) - } - > - - - - - + onChange={setFilter} + labels={reportConfig.segmentLabels} + />
{collections.length > 0 && (
From 115f00c3506d8a47ffd3f866a8f1b823e83ef903 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:53:34 +0000 Subject: [PATCH 36/45] fix(manager): add 2px gap between filter dropdown items --- apps/manager/src/app/globals.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 36b8b770..d1b1b2f0 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1636,6 +1636,7 @@ html[data-coverage-loading="true"] .collections::after { padding: 4px; display: flex; flex-direction: column; + gap: 2px; } .filter-dropdown-option { From 5fe3709c8aa01a60653aef008bddb6b9d5a8b361 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:54:03 +0000 Subject: [PATCH 37/45] fix(manager): remove thick border from collection tile --- apps/manager/src/app/globals.css | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index d1b1b2f0..19dc1c31 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2073,7 +2073,6 @@ html[data-coverage-loading="true"] .collections::after { .tile--collection { width: 32px; height: 32px; - border-width: 3px; border-radius: 4px; } From 23c328557e5d5b8d7ac999c460274fc4f52a4f82 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:54:25 +0000 Subject: [PATCH 38/45] fix(manager): match collection tile border-radius to video tiles --- apps/manager/src/app/globals.css | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 19dc1c31..ed26e511 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2073,7 +2073,6 @@ html[data-coverage-loading="true"] .collections::after { .tile--collection { width: 32px; height: 32px; - border-radius: 4px; } .tile--partial { From f1a55147f2298281f1f27e4c261c2f7ca1b4d3a7 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:55:36 +0000 Subject: [PATCH 39/45] fix(manager): prevent tile hover shadow from clipping at edges --- apps/manager/src/app/globals.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index ed26e511..9b0740b4 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -2001,6 +2001,7 @@ html[data-coverage-loading="true"] .collections::after { gap: 8px; margin-top: 6px; align-items: center; + padding: 2px; max-height: 240px; opacity: 1; transform: translateY(0); From 380b6695f19fea674c78c1e7e0711af7969073f4 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 02:58:37 +0000 Subject: [PATCH 40/45] feat(manager): add collection type filter dropdown Filter collections by type (Series, Feature Film, Standalone, etc.) using a dropdown next to the coverage segment filter. Standalone is included as an option. Clear filters resets all three filters. --- .../coverage/coverage-report-client.tsx | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 519b57c5..0005c699 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -614,19 +614,21 @@ function CoverageFilterDropdown({ value, onChange, labels, + options: customOptions, }: { - value: CoverageFilter - onChange: (value: CoverageFilter) => void - labels: Record + value: string + onChange: (value: string) => void + labels?: Record + options?: Array<{ value: string; label: string }> }) { const [isOpen, setIsOpen] = useState(false) const shellRef = useRef(null) - const options: Array<{ value: CoverageFilter; label: string }> = [ + const options: Array<{ value: string; label: string }> = customOptions ?? [ { value: "all", label: "All" }, - { value: "human", label: labels.human }, - { value: "ai", label: labels.ai }, - { value: "none", label: labels.none }, + { value: "human", label: labels?.human ?? "Verified" }, + { value: "ai", label: labels?.ai ?? "AI" }, + { value: "none", label: labels?.none ?? "None" }, ] const currentLabel = options.find((o) => o.value === value)?.label ?? "All" @@ -1153,6 +1155,7 @@ export function CoverageReportClient({ const hydrated = useHydrated() const reportConfig = REPORT_CONFIG[reportType] const [searchQuery, setSearchQuery] = useState("") + const [typeFilter, setTypeFilter] = useState("all") const [interactionMode, setInteractionMode] = useSessionMode("explore") const isSelectMode = interactionMode === "select" @@ -1243,6 +1246,18 @@ export function CoverageReportClient({ })() }, []) + const collectionTypeOptions = useMemo(() => { + const types = new Map() + for (const c of collections) { + if (c.label && !types.has(c.label)) { + types.set(c.label, c.labelDisplay) + } + } + return [...types.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([value, label]) => ({ value, label })) + }, [collections]) + // Derive header bar counts from snapshot data (instant, pre-computed) const snapshotCounts = useMemo(() => { if (!snapshot) return null @@ -1331,6 +1346,9 @@ export function CoverageReportClient({ const visibleCollections = useMemo(() => { let result = collections + if (typeFilter !== "all") { + result = result.filter((c) => c.label === typeFilter) + } if (effectiveFilter !== "all") { result = result .map((collection) => ({ @@ -1347,7 +1365,7 @@ export function CoverageReportClient({ ) } return result - }, [collections, effectiveFilter, searchMatchIds]) + }, [collections, typeFilter, effectiveFilter, searchMatchIds]) const toggleExpanded = useCallback((collectionId: string) => { setExpandedCollections((prev) => @@ -1452,9 +1470,18 @@ export function CoverageReportClient({ value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> + setFilter(v as CoverageFilter)} labels={reportConfig.segmentLabels} />
@@ -1466,12 +1493,13 @@ export function CoverageReportClient({ : ""}{" "} collection {collections.length === 1 ? "" : "s"} - {(filter !== "all" || searchQuery.trim()) && ( + {(filter !== "all" || typeFilter !== "all" || searchQuery.trim()) && (
)} {totalCollections > 0 && ( From b575b08d500bbf024f7bb617e2e27f3d242dc7b9 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Thu, 2 Apr 2026 03:02:15 +0000 Subject: [PATCH 42/45] fix(manager): fixed-width filter dropdown based on widest option --- apps/manager/src/app/globals.css | 22 ++++++++++++++++++- .../coverage/coverage-report-client.tsx | 9 +++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 302c48cb..7d56c0a7 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -1606,7 +1606,8 @@ html[data-coverage-loading="true"] .collections::after { } .filter-dropdown-trigger { - display: inline-flex; + display: inline-grid; + grid-template-columns: 1fr auto; align-items: center; gap: 6px; padding: 10px 18px; @@ -1619,6 +1620,25 @@ html[data-coverage-loading="true"] .collections::after { white-space: nowrap; } +.filter-dropdown-sizer { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + visibility: hidden; + height: 0; + overflow: hidden; +} + +.filter-dropdown-sizer-item { + display: block; +} + +.filter-dropdown-label { + grid-column: 1; + grid-row: 1; +} + .filter-dropdown-trigger:hover { background: var(--color-hover, #f1f5f9); } diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index c8481f6d..d379da87 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -659,7 +659,14 @@ function CoverageFilterDropdown({ aria-expanded={isOpen} onClick={() => setIsOpen((prev) => !prev)} > - {currentLabel} + + {currentLabel}