diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3f305ce1..fed47b5f 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -34559,6 +34559,13 @@ "format": "uuid", "description": "Associated book ID (if applicable)" }, + "bookTitle": { + "type": [ + "string", + "null" + ], + "description": "Resolved title of the associated book (from `book_metadata.title`).\nPopulated only when the task is book-scoped and metadata exists." + }, "completedAt": { "type": [ "string", @@ -34595,6 +34602,13 @@ "description": "Associated library ID (if applicable)", "example": "550e8400-e29b-41d4-a716-446655440001" }, + "libraryName": { + "type": [ + "string", + "null" + ], + "description": "Resolved name of the associated library (from `libraries.name`).\nPopulated only when the task is library-scoped." + }, "lockedBy": { "type": [ "string", @@ -34643,6 +34657,13 @@ "format": "uuid", "description": "Associated series ID (if applicable)" }, + "seriesTitle": { + "type": [ + "string", + "null" + ], + "description": "Resolved title of the associated series (from `series_metadata.title`).\nPopulated only when the task is series-scoped and metadata exists." + }, "startedAt": { "type": [ "string", diff --git a/src/api/routes/v1/handlers/task_queue.rs b/src/api/routes/v1/handlers/task_queue.rs index fcd6d507..f630d19d 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/src/api/routes/v1/handlers/task_queue.rs @@ -126,6 +126,21 @@ pub struct TaskResponse { /// When task execution completed pub completed_at: Option>, + + /// Resolved title of the associated book (from `book_metadata.title`). + /// Populated only when the task is book-scoped and metadata exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub book_title: Option, + + /// Resolved title of the associated series (from `series_metadata.title`). + /// Populated only when the task is series-scoped and metadata exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub series_title: Option, + + /// Resolved name of the associated library (from `libraries.name`). + /// Populated only when the task is library-scoped. + #[serde(skip_serializing_if = "Option::is_none")] + pub library_name: Option, } impl From for TaskResponse { @@ -149,10 +164,23 @@ impl From for TaskResponse { created_at: task.created_at, started_at: task.started_at, completed_at: task.completed_at, + book_title: None, + series_title: None, + library_name: None, } } } +impl From for TaskResponse { + fn from(enriched: crate::db::repositories::task::TaskWithTargets) -> Self { + let mut response = Self::from(enriched.task); + response.book_title = enriched.book_title; + response.series_title = enriched.series_title; + response.library_name = enriched.library_name; + response + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PurgeTasksParams { @@ -208,7 +236,7 @@ pub async fn list_tasks( // Check permission auth.require_permission(&Permission::TasksRead)?; - let tasks = TaskRepository::list( + let tasks = TaskRepository::list_with_targets( &state.db, params.status, params.task_type, diff --git a/src/db/repositories/task.rs b/src/db/repositories/task.rs index c684a80a..65e3ab8e 100644 --- a/src/db/repositories/task.rs +++ b/src/db/repositories/task.rs @@ -2,15 +2,98 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Utc}; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, DbBackend, EntityTrait, - QueryFilter, QueryOrder, QuerySelect, Set, Statement, TransactionTrait, entity::prelude::*, + FromQueryResult, JoinType, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, Statement, + TransactionTrait, entity::prelude::*, }; use tracing::{info, warn}; use uuid::Uuid; -use crate::db::entities::{prelude::*, tasks}; +use crate::db::entities::{ + book_metadata, books, libraries, prelude::*, series, series_metadata, tasks, +}; use crate::tasks::error::DEFAULT_MAX_RESCHEDULES; use crate::tasks::types::{TaskStats, TaskType}; +/// Task row enriched with the resolved title of its target (book, series, or library). +/// +/// Returned by [`TaskRepository::list_with_targets`]. Use this for UI surfaces that +/// need a human-readable label for each task (e.g. the active-tasks tooltip). +/// For internal task-processing call sites that only need the `tasks` row, +/// prefer [`TaskRepository::list`] to avoid the join overhead. +#[derive(Debug, Clone)] +pub struct TaskWithTargets { + pub task: tasks::Model, + /// Title from `book_metadata.title` when `task.book_id` is set and metadata exists. + pub book_title: Option, + /// Title from `series_metadata.title` when `task.series_id` is set and metadata exists. + pub series_title: Option, + /// Name from `libraries.name` when `task.library_id` is set. + pub library_name: Option, +} + +/// Internal flat row used to deserialise the joined query result. +/// Mirrors [`tasks::Model`] plus three nullable target-name columns. +#[derive(Debug, FromQueryResult)] +struct TaskWithTargetsRow { + // tasks columns + id: Uuid, + task_type: String, + library_id: Option, + series_id: Option, + book_id: Option, + params: Option, + status: String, + priority: i32, + locked_by: Option, + locked_until: Option>, + attempts: i32, + max_attempts: i32, + last_error: Option, + reschedule_count: i32, + max_reschedules: i32, + result: Option, + scheduled_for: DateTime, + created_at: DateTime, + started_at: Option>, + completed_at: Option>, + // joined target columns (aliased to avoid `id`/`title` collisions) + book_title: Option, + series_title: Option, + library_name: Option, +} + +impl From for TaskWithTargets { + fn from(row: TaskWithTargetsRow) -> Self { + Self { + task: tasks::Model { + id: row.id, + task_type: row.task_type, + library_id: row.library_id, + series_id: row.series_id, + book_id: row.book_id, + params: row.params, + status: row.status, + priority: row.priority, + locked_by: row.locked_by, + locked_until: row.locked_until, + attempts: row.attempts, + max_attempts: row.max_attempts, + last_error: row.last_error, + reschedule_count: row.reschedule_count, + max_reschedules: row.max_reschedules, + result: row.result, + scheduled_for: row.scheduled_for, + created_at: row.created_at, + started_at: row.started_at, + completed_at: row.completed_at, + }, + book_title: row.book_title, + series_title: row.series_title, + library_name: row.library_name, + } + } +} + /// Repository for Task operations pub struct TaskRepository; @@ -798,6 +881,77 @@ impl TaskRepository { .context("Failed to list tasks") } + /// List tasks with their target titles (book / series / library) resolved via LEFT JOIN. + /// + /// Use this for UI surfaces (e.g. the active-tasks tooltip) that need a human-readable + /// label per task. The query projects only `tasks.*`, `book_metadata.title`, + /// `series_metadata.title`, and `libraries.name` to avoid pulling heavy columns + /// (covers, descriptions) from the joined entities. + /// + /// Indexes used: `idx_tasks_status` for the status filter; `idx_tasks_book`, + /// `idx_tasks_series`, `idx_tasks_library` for the FK joins. Confirmed in + /// `migration/src/m20260106_000019_create_tasks.rs`. + pub async fn list_with_targets( + db: &DatabaseConnection, + status: Option, + task_type: Option, + limit: Option, + ) -> Result> { + let mut query = Tasks::find() + .select_only() + // tasks columns + .column(tasks::Column::Id) + .column(tasks::Column::TaskType) + .column(tasks::Column::LibraryId) + .column(tasks::Column::SeriesId) + .column(tasks::Column::BookId) + .column(tasks::Column::Params) + .column(tasks::Column::Status) + .column(tasks::Column::Priority) + .column(tasks::Column::LockedBy) + .column(tasks::Column::LockedUntil) + .column(tasks::Column::Attempts) + .column(tasks::Column::MaxAttempts) + .column(tasks::Column::LastError) + .column(tasks::Column::RescheduleCount) + .column(tasks::Column::MaxReschedules) + .column(tasks::Column::Result) + .column(tasks::Column::ScheduledFor) + .column(tasks::Column::CreatedAt) + .column(tasks::Column::StartedAt) + .column(tasks::Column::CompletedAt) + // joined target name columns (aliased to avoid collisions on `id` / `title`) + .column_as(book_metadata::Column::Title, "book_title") + .column_as(series_metadata::Column::Title, "series_title") + .column_as(libraries::Column::Name, "library_name") + .join(JoinType::LeftJoin, tasks::Relation::Books.def()) + .join(JoinType::LeftJoin, books::Relation::BookMetadata.def()) + .join(JoinType::LeftJoin, tasks::Relation::Series.def()) + .join(JoinType::LeftJoin, series::Relation::SeriesMetadata.def()) + .join(JoinType::LeftJoin, tasks::Relation::Libraries.def()); + + if let Some(s) = status { + query = query.filter(tasks::Column::Status.eq(s)); + } + + if let Some(t) = task_type { + query = query.filter(tasks::Column::TaskType.eq(t)); + } + + if let Some(l) = limit { + query = query.limit(l); + } + + let rows = query + .order_by_desc(tasks::Column::CreatedAt) + .into_model::() + .all(db) + .await + .context("Failed to list tasks with targets")?; + + Ok(rows.into_iter().map(TaskWithTargets::from).collect()) + } + /// Get task by ID pub async fn get_by_id(db: &DatabaseConnection, task_id: Uuid) -> Result> { Tasks::find_by_id(task_id) diff --git a/tests/db/repositories.rs b/tests/db/repositories.rs index 90823715..c1fa8020 100644 --- a/tests/db/repositories.rs +++ b/tests/db/repositories.rs @@ -6,9 +6,11 @@ use codex::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use codex::db::Database; use codex::db::entities::{books, libraries, read_progress, series, users}; use codex::db::repositories::{ - BookRepository, LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, + BookMetadataRepository, BookRepository, LibraryRepository, PageRepository, + SeriesMetadataRepository, SeriesRepository, TaskRepository, }; use codex::models::ScanningStrategy; +use codex::tasks::types::TaskType; use common::*; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, Set, @@ -1510,3 +1512,161 @@ async fn test_purge_deleted_in_series_keeps_series_when_not_empty() { db.close().await; } + +// ============================================================================ +// TaskRepository::list_with_targets Tests +// ============================================================================ + +#[tokio::test] +async fn test_list_with_targets_resolves_book_series_library_titles() { + let (db, _temp_dir) = setup_test_db_wrapper().await; + let conn = db.sea_orm_connection(); + + // Library is named directly via libraries.name. + let library = LibraryRepository::create( + conn, + "Manga Library", + "/lib/manga", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + // SeriesRepository::create populates series_metadata.title from `name`. + let series = SeriesRepository::create(conn, library.id, "Naruto", None) + .await + .unwrap(); + + // Book row carries no title; book_metadata.title is what we surface. + let book = create_test_book_with_hash( + conn, + &library, + &series, + "ignored", + "/lib/manga/naruto/v1.cbz", + "abc123", + ) + .await; + BookMetadataRepository::create_with_title_and_number( + conn, + book.id, + Some("Naruto Vol. 12".to_string()), + None, + ) + .await + .unwrap(); + + // Enqueue three tasks scoped at each level. + TaskRepository::enqueue( + conn, + TaskType::AnalyzeBook { + book_id: book.id, + force: false, + }, + None, + ) + .await + .unwrap(); + TaskRepository::enqueue( + conn, + TaskType::AnalyzeSeries { + series_id: series.id, + }, + None, + ) + .await + .unwrap(); + TaskRepository::enqueue( + conn, + TaskType::ScanLibrary { + library_id: library.id, + mode: "normal".to_string(), + }, + None, + ) + .await + .unwrap(); + + let rows = TaskRepository::list_with_targets(conn, None, None, Some(50)) + .await + .unwrap(); + + assert_eq!(rows.len(), 3, "expected three enqueued tasks"); + + // Find each task by its task_type and verify the resolved target name. + let book_task = rows + .iter() + .find(|r| r.task.task_type == "analyze_book") + .expect("analyze_book row missing"); + assert_eq!(book_task.book_title.as_deref(), Some("Naruto Vol. 12")); + assert_eq!(book_task.series_title, None); + assert_eq!(book_task.library_name, None); + + let series_task = rows + .iter() + .find(|r| r.task.task_type == "analyze_series") + .expect("analyze_series row missing"); + assert_eq!(series_task.series_title.as_deref(), Some("Naruto")); + assert_eq!(series_task.book_title, None); + assert_eq!(series_task.library_name, None); + + let library_task = rows + .iter() + .find(|r| r.task.task_type == "scan_library") + .expect("scan_library row missing"); + assert_eq!(library_task.library_name.as_deref(), Some("Manga Library")); + assert_eq!(library_task.book_title, None); + assert_eq!(library_task.series_title, None); + + db.close().await; +} + +#[tokio::test] +async fn test_list_with_targets_omits_titles_when_metadata_missing() { + let (db, _temp_dir) = setup_test_db_wrapper().await; + let conn = db.sea_orm_connection(); + + let library = + LibraryRepository::create(conn, "Comics", "/lib/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(conn, library.id, "Mystery Series", None) + .await + .unwrap(); + let book = create_test_book_with_hash( + conn, + &library, + &series, + "ignored", + "/lib/comics/mystery/i1.cbz", + "def456", + ) + .await; + // Intentionally do NOT create book_metadata: book_title should remain None + // even though tasks.book_id is populated. + + TaskRepository::enqueue( + conn, + TaskType::AnalyzeBook { + book_id: book.id, + force: false, + }, + None, + ) + .await + .unwrap(); + + let rows = TaskRepository::list_with_targets(conn, None, None, Some(10)) + .await + .unwrap(); + let task = rows + .iter() + .find(|r| r.task.task_type == "analyze_book") + .expect("analyze_book row missing"); + assert!( + task.book_title.is_none(), + "book_title should be None when book_metadata row is absent" + ); + + db.close().await; +} diff --git a/tests/task_queue_api.rs b/tests/task_queue_api.rs index 7746073e..81a5f093 100644 --- a/tests/task_queue_api.rs +++ b/tests/task_queue_api.rs @@ -1,12 +1,16 @@ mod common; -use codex::db::repositories::{TaskRepository, UserRepository}; +use codex::db::repositories::{ + BookMetadataRepository, LibraryRepository, SeriesRepository, TaskRepository, UserRepository, +}; +use codex::models::ScanningStrategy; use codex::tasks::types::TaskType; use codex::utils::password; use common::{ - create_test_app_state, create_test_router_with_app_state, create_test_user_with_permissions, - delete_request_with_auth, get_request_with_auth, make_json_request, make_request, - post_json_request_with_auth, post_request_with_auth, setup_test_db, + create_test_app_state, create_test_book_with_hash, create_test_router_with_app_state, + create_test_user_with_permissions, delete_request_with_auth, get_request_with_auth, + make_json_request, make_request, post_json_request_with_auth, post_request_with_auth, + setup_test_db, }; use hyper::StatusCode; use serde_json::json; @@ -381,3 +385,135 @@ async fn test_api_nuke_tasks_admin_only() { // Should succeed for admin assert_eq!(status, StatusCode::OK); } + +/// Verify that GET /api/v1/tasks resolves bookTitle / seriesTitle / libraryName +/// from the joined metadata tables, so the active-tasks UI can render labels +/// without follow-up requests. +#[tokio::test] +async fn test_api_list_tasks_resolves_target_titles() { + let (db, _temp_dir) = setup_test_db().await; + + // Auth: a user with tasks-read permission + let password = "test_password"; + let password_hash = password::hash_password(password).unwrap(); + let user = create_test_user_with_permissions( + "tasks_reader", + "tasks_reader@example.com", + &password_hash, + false, + vec!["tasks-read".to_string()], + ); + UserRepository::create(&db, &user).await.unwrap(); + + let state = create_test_app_state(db.clone()).await; + let app = create_test_router_with_app_state(state.clone()); + + let login_request = json!({"username": "tasks_reader", "password": password}); + let request = post_json_request_with_auth("/api/v1/auth/login", &login_request, ""); + let (login_status, login_response): (StatusCode, Option) = + make_json_request(app.clone(), request).await; + assert_eq!(login_status, StatusCode::OK); + let token = login_response.unwrap()["accessToken"] + .as_str() + .unwrap() + .to_string(); + + // Build a library / series / book with metadata so the joins have something to resolve. + let library = LibraryRepository::create( + &db, + "Manga Library", + "/lib/manga", + ScanningStrategy::Default, + ) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Naruto", None) + .await + .unwrap(); + let book = create_test_book_with_hash( + &db, + &library, + &series, + "ignored", + "/lib/manga/naruto/v12.cbz", + "hash_v12", + ) + .await; + BookMetadataRepository::create_with_title_and_number( + &db, + book.id, + Some("Naruto Vol. 12".to_string()), + None, + ) + .await + .unwrap(); + + // Enqueue tasks at all three scopes so we can assert each title field independently. + TaskRepository::enqueue( + &db, + TaskType::AnalyzeBook { + book_id: book.id, + force: false, + }, + None, + ) + .await + .unwrap(); + TaskRepository::enqueue( + &db, + TaskType::AnalyzeSeries { + series_id: series.id, + }, + None, + ) + .await + .unwrap(); + TaskRepository::enqueue( + &db, + TaskType::ScanLibrary { + library_id: library.id, + mode: "normal".to_string(), + }, + None, + ) + .await + .unwrap(); + + let request = get_request_with_auth("/api/v1/tasks", &token); + let (status, body): (StatusCode, Option) = + make_json_request(app, request).await; + assert_eq!(status, StatusCode::OK); + + let body = body.expect("response body"); + let tasks = body.as_array().expect("array of tasks"); + assert!( + tasks.len() >= 3, + "expected at least three tasks, got {}", + tasks.len() + ); + + let book_task = tasks + .iter() + .find(|t| t["taskType"] == "analyze_book") + .expect("analyze_book task missing"); + assert_eq!(book_task["bookTitle"], "Naruto Vol. 12"); + // seriesTitle / libraryName are skipped via skip_serializing_if when None. + assert!(book_task.get("seriesTitle").is_none()); + assert!(book_task.get("libraryName").is_none()); + + let series_task = tasks + .iter() + .find(|t| t["taskType"] == "analyze_series") + .expect("analyze_series task missing"); + assert_eq!(series_task["seriesTitle"], "Naruto"); + assert!(series_task.get("bookTitle").is_none()); + assert!(series_task.get("libraryName").is_none()); + + let library_task = tasks + .iter() + .find(|t| t["taskType"] == "scan_library") + .expect("scan_library task missing"); + assert_eq!(library_task["libraryName"], "Manga Library"); + assert!(library_task.get("bookTitle").is_none()); + assert!(library_task.get("seriesTitle").is_none()); +} diff --git a/web/openapi.json b/web/openapi.json index 3f305ce1..fed47b5f 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -34559,6 +34559,13 @@ "format": "uuid", "description": "Associated book ID (if applicable)" }, + "bookTitle": { + "type": [ + "string", + "null" + ], + "description": "Resolved title of the associated book (from `book_metadata.title`).\nPopulated only when the task is book-scoped and metadata exists." + }, "completedAt": { "type": [ "string", @@ -34595,6 +34602,13 @@ "description": "Associated library ID (if applicable)", "example": "550e8400-e29b-41d4-a716-446655440001" }, + "libraryName": { + "type": [ + "string", + "null" + ], + "description": "Resolved name of the associated library (from `libraries.name`).\nPopulated only when the task is library-scoped." + }, "lockedBy": { "type": [ "string", @@ -34643,6 +34657,13 @@ "format": "uuid", "description": "Associated series ID (if applicable)" }, + "seriesTitle": { + "type": [ + "string", + "null" + ], + "description": "Resolved title of the associated series (from `series_metadata.title`).\nPopulated only when the task is series-scoped and metadata exists." + }, "startedAt": { "type": [ "string", diff --git a/web/src/components/TaskNotificationBadge.tsx b/web/src/components/TaskNotificationBadge.tsx index 2604b4ab..40ac95b0 100644 --- a/web/src/components/TaskNotificationBadge.tsx +++ b/web/src/components/TaskNotificationBadge.tsx @@ -1,9 +1,13 @@ -import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { Badge, Group, Progress, Stack, Text, Tooltip } from "@mantine/core"; import { IconLoader2 } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { usePermissions } from "@/hooks/usePermissions"; import { useTaskProgress } from "@/hooks/useTaskProgress"; +import type { ActiveTask } from "@/types"; import { PERMISSIONS } from "@/types/permissions"; +import { elapsedSince, formatElapsed } from "@/utils/duration"; +import { getTaskTarget } from "@/utils/tasks"; /** * Task notification badge that appears at the bottom of the navigation sidebar @@ -16,52 +20,122 @@ export function TaskNotificationBadge() { const canReadTasks = hasPermission(PERMISSIONS.TASKS_READ); const { activeTasks, pendingCounts } = useTaskProgress(); - // Don't show task badge for users without TASKS_READ permission + // Filter to only running tasks (processing tasks are shown as running). + // pending tasks are shown separately via pendingCounts, not from activeTasks. + const runningTasks = activeTasks.filter((task) => task.status === "running"); + const hasRunning = runningTasks.length > 0; + + // Tick once per second only while running tasks exist, so elapsed times + // refresh without burning CPU when the panel is otherwise idle. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!hasRunning) return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [hasRunning]); + if (!canReadTasks) { return null; } - // Filter to only running tasks (processing tasks are shown as running) - // Note: pending tasks are shown separately via pendingCounts, not from activeTasks - const runningTasks = activeTasks.filter((task) => task.status === "running"); - - // Calculate total pending count const totalPendingCount = Object.values(pendingCounts).reduce( (sum, count) => sum + count, 0, ); - // If no running tasks and no pending tasks, don't show the badge - if (runningTasks.length === 0 && totalPendingCount === 0) { + if (!hasRunning && totalPendingCount === 0) { return null; } - const formatTaskType = (type: string) => { - return type + const formatTaskType = (type: string) => + type .replace(/([A-Z])/g, " $1") .replace(/_/g, " ") .trim() .split(" ") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); - }; - const getTaskSummary = (task: (typeof runningTasks)[0]) => { + const renderRunningTask = (task: ActiveTask) => { const taskName = formatTaskType(task.taskType); - const progress = task.progress + const target = getTaskTarget(task); + const elapsed = formatElapsed(elapsedSince(task.startedAt, now)); + const progressSuffix = task.progress ? ` (${task.progress.current}/${task.progress.total})` : ""; - return `${taskName}${progress}`; + + // Show the progress bar only when total is positive. Discovery-phase + // updates emit total=0 while files are still being enumerated, so a bar + // would be meaningless and would jitter to 0%. + const total = task.progress?.total ?? 0; + const current = task.progress?.current ?? 0; + const showBar = total > 0; + const percent = showBar ? Math.min(100, (current / total) * 100) : 0; + const message = task.progress?.message ?? null; + + return ( + + + + + {taskName} + {progressSuffix} + + {target ? ( + + · {target} + + ) : null} + + · {elapsed} + + + {showBar ? ( + + ) : null} + {message ? ( + // RTL ellipsis keeps the meaningful tail of long file paths visible + // while the leading directories truncate. + + {message} + + ) : null} + + ); }; - // Sort running tasks by formatted task type name const sortedRunningTasks = [...runningTasks].sort((a, b) => { const nameA = formatTaskType(a.taskType); const nameB = formatTaskType(b.taskType); return nameA.localeCompare(nameB); }); - // Filter and sort pending task entries by formatted name, excluding entries with 0 count const sortedPendingEntries = Object.entries(pendingCounts) .filter(([, count]) => count > 0) .sort(([typeA], [typeB]) => { @@ -77,16 +151,7 @@ export function TaskNotificationBadge() { Running Tasks - {sortedRunningTasks.map((task) => ( - - - {getTaskSummary(task)} - - ))} + {sortedRunningTasks.map(renderRunningTask)} )} diff --git a/web/src/hooks/useTaskProgress.ts b/web/src/hooks/useTaskProgress.ts index a894d72e..d7693e44 100644 --- a/web/src/hooks/useTaskProgress.ts +++ b/web/src/hooks/useTaskProgress.ts @@ -7,7 +7,7 @@ import { } from "@/api/tasks"; import { usePermissions } from "@/hooks/usePermissions"; import { useAuthStore } from "@/store/authStore"; -import type { TaskProgressEvent, TaskStatus } from "@/types"; +import type { ActiveTask, TaskProgressEvent, TaskStatus } from "@/types"; import { PERMISSIONS } from "@/types/permissions"; type ConnectionState = "connecting" | "connected" | "disconnected" | "failed"; @@ -31,9 +31,9 @@ export function useTaskProgress() { const { isAuthenticated } = useAuthStore(); const { hasPermission } = usePermissions(); const canReadTasks = hasPermission(PERMISSIONS.TASKS_READ); - const [activeTasks, setActiveTasks] = useState< - Map - >(new Map()); + const [activeTasks, setActiveTasks] = useState>( + new Map(), + ); const [connectionState, setConnectionState] = useState("disconnected"); const [pendingCounts, setPendingCounts] = useState({}); @@ -64,7 +64,8 @@ export function useTaskProgress() { } hasSubscribedRef.current = true; - // Convert API task response to TaskProgressEvent format + // Convert API task response to ActiveTask format. Titles come from the + // polling snapshot (`GET /api/v1/tasks`); SSE events do not carry them. const convertTaskToEvent = (task: { id: string; taskType: string; @@ -73,7 +74,10 @@ export function useTaskProgress() { seriesId?: string | null; bookId?: string | null; startedAt?: string | null; - }): TaskProgressEvent => { + bookTitle?: string | null; + seriesTitle?: string | null; + libraryName?: string | null; + }): ActiveTask => { // Map "processing" status to "running" for UI consistency const status: TaskStatus = task.status === "processing" ? "running" : (task.status as TaskStatus); @@ -89,6 +93,9 @@ export function useTaskProgress() { libraryId: task.libraryId ?? undefined, seriesId: task.seriesId ?? undefined, bookId: task.bookId ?? undefined, + bookTitle: task.bookTitle ?? undefined, + seriesTitle: task.seriesTitle ?? undefined, + libraryName: task.libraryName ?? undefined, }; }; @@ -221,7 +228,17 @@ export function useTaskProgress() { }, 5000); } - next.set(event.taskId, event); + // SSE events do not carry resolved target titles. Preserve any titles + // already stashed on this task from the most recent polling snapshot + // so the UI keeps showing the human-readable label across progress + // updates. + const existing = prev.get(event.taskId); + next.set(event.taskId, { + ...event, + bookTitle: existing?.bookTitle, + seriesTitle: existing?.seriesTitle, + libraryName: existing?.libraryName, + }); return next; }); }; @@ -251,7 +268,7 @@ export function useTaskProgress() { }, [isAuthenticated, canReadTasks]); // Sort helper for consistent ordering (by task_type alphabetically) - const sortTasks = (tasks: TaskProgressEvent[]): TaskProgressEvent[] => + const sortTasks = (tasks: ActiveTask[]): ActiveTask[] => tasks.sort((a, b) => a.taskType.localeCompare(b.taskType)); return { @@ -270,7 +287,7 @@ export function useTaskProgress() { /** * Get all tasks with a specific status (sorted by task_type) */ - getTasksByStatus: (status: TaskStatus): TaskProgressEvent[] => { + getTasksByStatus: (status: TaskStatus): ActiveTask[] => { return sortTasks( Array.from(activeTasks.values()).filter( (task) => task.status === status, @@ -280,7 +297,7 @@ export function useTaskProgress() { /** * Get all tasks for a specific library (sorted by task_type) */ - getTasksByLibrary: (libraryId: string): TaskProgressEvent[] => { + getTasksByLibrary: (libraryId: string): ActiveTask[] => { return sortTasks( Array.from(activeTasks.values()).filter( (task) => task.libraryId === libraryId, @@ -290,7 +307,7 @@ export function useTaskProgress() { /** * Get a specific task by ID */ - getTask: (taskId: string): TaskProgressEvent | undefined => { + getTask: (taskId: string): ActiveTask | undefined => { return activeTasks.get(taskId); }, }; diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index c835d400..d9ec8e98 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -15941,6 +15941,11 @@ export interface components { * @description Associated book ID (if applicable) */ bookId?: string | null; + /** + * @description Resolved title of the associated book (from `book_metadata.title`). + * Populated only when the task is book-scoped and metadata exists. + */ + bookTitle?: string | null; /** * Format: date-time * @description When task execution completed @@ -15966,6 +15971,11 @@ export interface components { * @example 550e8400-e29b-41d4-a716-446655440001 */ libraryId?: string | null; + /** + * @description Resolved name of the associated library (from `libraries.name`). + * Populated only when the task is library-scoped. + */ + libraryName?: string | null; /** * @description Worker ID that has locked this task * @example worker-1 @@ -16003,6 +16013,11 @@ export interface components { * @description Associated series ID (if applicable) */ seriesId?: string | null; + /** + * @description Resolved title of the associated series (from `series_metadata.title`). + * Populated only when the task is series-scoped and metadata exists. + */ + seriesTitle?: string | null; /** * Format: date-time * @description When task execution started diff --git a/web/src/types/index.ts b/web/src/types/index.ts index a44c7466..93e1d708 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -176,6 +176,17 @@ export type TaskProgress = Schemas["TaskProgress"]; export type TaskProgressEvent = Schemas["TaskProgressEvent"]; export type TaskResponse = Schemas["TaskResponse"]; +/** + * Frontend-only active-task shape that augments `TaskProgressEvent` with the + * resolved target titles returned by `GET /api/v1/tasks` (which reach the + * client via the polling snapshot, not the SSE stream). + */ +export type ActiveTask = TaskProgressEvent & { + bookTitle?: string | null; + seriesTitle?: string | null; + libraryName?: string | null; +}; + // ============================================================================= // Type guards for entity events // ============================================================================= diff --git a/web/src/utils/duration.test.ts b/web/src/utils/duration.test.ts new file mode 100644 index 00000000..40581f3c --- /dev/null +++ b/web/src/utils/duration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { elapsedSince, formatElapsed } from "./duration"; + +describe("formatElapsed", () => { + it("renders sub-second values as 0s", () => { + expect(formatElapsed(400)).toBe("0s"); + expect(formatElapsed(0)).toBe("0s"); + }); + + it("renders seconds-only durations", () => { + expect(formatElapsed(12_000)).toBe("12s"); + expect(formatElapsed(59_000)).toBe("59s"); + }); + + it("renders minute + second durations", () => { + expect(formatElapsed(60_000)).toBe("1m 0s"); + expect(formatElapsed(84_000)).toBe("1m 24s"); + expect(formatElapsed(59 * 60 * 1000 + 30_000)).toBe("59m 30s"); + }); + + it("renders hour + minute durations and drops seconds past the hour mark", () => { + expect(formatElapsed(3_600_000)).toBe("1h 0m"); + expect(formatElapsed(7_500_000)).toBe("2h 5m"); + }); + + it("clamps invalid input to 0s", () => { + expect(formatElapsed(-5)).toBe("0s"); + expect(formatElapsed(Number.NaN)).toBe("0s"); + expect(formatElapsed(Number.POSITIVE_INFINITY)).toBe("0s"); + }); +}); + +describe("elapsedSince", () => { + it("returns 0 for missing or invalid timestamps", () => { + expect(elapsedSince(null)).toBe(0); + expect(elapsedSince(undefined)).toBe(0); + expect(elapsedSince("not-a-date")).toBe(0); + }); + + it("computes elapsed milliseconds against the supplied now", () => { + const startedAt = "2026-05-04T12:00:00.000Z"; + const now = Date.parse("2026-05-04T12:00:30.000Z"); + expect(elapsedSince(startedAt, now)).toBe(30_000); + }); + + it("clamps future startedAt values to 0", () => { + const startedAt = "2030-01-01T00:00:00.000Z"; + const now = Date.parse("2026-01-01T00:00:00.000Z"); + expect(elapsedSince(startedAt, now)).toBe(0); + }); +}); diff --git a/web/src/utils/duration.ts b/web/src/utils/duration.ts new file mode 100644 index 00000000..6f86a3fb --- /dev/null +++ b/web/src/utils/duration.ts @@ -0,0 +1,44 @@ +/** + * Format an elapsed duration in milliseconds as a compact human-readable + * string for tooltip-style displays. + * + * Output examples: + * 400 -> "0s" + * 12_000 -> "12s" + * 84_000 -> "1m 24s" + * 7_500_000 -> "2h 5m" + * + * Hours suppress the seconds component to keep the string short. Negative + * inputs are clamped to 0. + */ +export function formatElapsed(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) { + return "0s"; + } + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +/** + * Compute elapsed milliseconds between an ISO timestamp and `now`. + * Returns 0 if `startedAt` is missing or unparseable. + */ +export function elapsedSince( + startedAt: string | null | undefined, + now: number = Date.now(), +): number { + if (!startedAt) return 0; + const started = Date.parse(startedAt); + if (Number.isNaN(started)) return 0; + return Math.max(0, now - started); +} diff --git a/web/src/utils/tasks.test.ts b/web/src/utils/tasks.test.ts new file mode 100644 index 00000000..bca4edb0 --- /dev/null +++ b/web/src/utils/tasks.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { ActiveTask } from "@/types"; +import { getTaskTarget } from "./tasks"; + +const baseTask: ActiveTask = { + taskId: "00000000-0000-0000-0000-000000000000", + taskType: "analyze_book", + status: "running", + startedAt: "2026-05-04T12:00:00.000Z", +}; + +describe("getTaskTarget", () => { + it("prefers bookTitle over series and library", () => { + expect( + getTaskTarget({ + ...baseTask, + bookTitle: "Naruto Vol. 12", + seriesTitle: "Naruto", + libraryName: "Manga Library", + }), + ).toBe("Naruto Vol. 12"); + }); + + it("falls back to seriesTitle when book is absent", () => { + expect( + getTaskTarget({ + ...baseTask, + seriesTitle: "Naruto", + libraryName: "Manga Library", + }), + ).toBe("Naruto"); + }); + + it("falls back to libraryName when neither book nor series is set", () => { + expect( + getTaskTarget({ + ...baseTask, + libraryName: "Manga Library", + }), + ).toBe("Manga Library"); + }); + + it("returns null when no target is set", () => { + expect(getTaskTarget(baseTask)).toBeNull(); + }); + + it("treats explicit nulls as missing", () => { + expect( + getTaskTarget({ + ...baseTask, + bookTitle: null, + seriesTitle: null, + libraryName: null, + }), + ).toBeNull(); + }); +}); diff --git a/web/src/utils/tasks.ts b/web/src/utils/tasks.ts new file mode 100644 index 00000000..5a1da6fc --- /dev/null +++ b/web/src/utils/tasks.ts @@ -0,0 +1,12 @@ +import type { ActiveTask } from "@/types"; + +/** + * Resolve the most-specific human-readable label for a task's target. + * + * Precedence: book title -> series title -> library name. Returns `null` when + * none of the three are populated (e.g. library-wide cleanup tasks with no + * scoped target). + */ +export function getTaskTarget(task: ActiveTask): string | null { + return task.bookTitle ?? task.seriesTitle ?? task.libraryName ?? null; +}