Files
descrybe/apps/web/src/lib/components/TaskStatusIndicator.svelte
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

273 lines
8.9 KiB
Svelte

<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/state";
import { onMount } from "svelte";
import { api } from "$lib/api";
import { unwrapList } from "$lib/list";
import { i18n } from "$lib/i18n";
import {
formatJobErrorText,
formatJobStatusLabel,
formatProcessingTypeLabel,
isActiveProcessingJob
} from "$lib/job-status";
import { notifyError, notifySuccess } from "$lib/notify";
import type { ListResponse, ProcessingJob } from "$lib/types";
import { Badge, Progress, buttonClasses } from "$lib/components/ui";
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
const POLL_MS = 2_000;
const RETRY_MS = 10_000;
/** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
const LINGER_MS = 6_000;
let jobs = $state<ProcessingJob[]>([]);
let displayJobs = $state<ProcessingJob[]>([]);
let collapsed = $state(false);
let timer: number | undefined;
let lingerTimer: number | undefined;
let abort: AbortController | null = null;
let gen = 0;
/** Jobs observed as active this session — terminal toasts only for transitions we watched. */
const watchedActiveIds = new Set<string>();
const toastedTerminalIds = new Set<string>();
const activeJobs = $derived(jobs.filter((j) => isActiveProcessingJob(j.status)));
const visible = $derived(
displayJobs.length > 0 && !page.url.pathname.startsWith("/processing")
);
const headline = $derived(
displayJobs.length === 1
? i18n.t("taskIndicator.activeOne")
: i18n.t("taskIndicator.activeMany", {
count: Math.max(displayJobs.length, activeJobs.length)
})
);
const previewJobs = $derived(displayJobs.slice(0, 3));
function isTerminalSuccess(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "completed" || s === "success" || s === "done";
}
function isTerminalFailure(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "failed" || s === "error";
}
function isTerminalCancelled(status: string | null | undefined): boolean {
const s = (status ?? "").toLowerCase().trim();
return s === "cancelled" || s === "canceled";
}
/** Fire ai_done / ai_fail toasts when a watched active job reaches a terminal status. */
function notifyTerminalTransitions(nextJobs: ProcessingJob[]) {
for (const job of nextJobs) {
const id = String(job.id ?? "").trim();
if (!id) continue;
if (isActiveProcessingJob(job.status)) {
watchedActiveIds.add(id);
continue;
}
if (!watchedActiveIds.has(id) || toastedTerminalIds.has(id)) {
if (isTerminalCancelled(job.status) || isTerminalSuccess(job.status) || isTerminalFailure(job.status)) {
watchedActiveIds.delete(id);
}
continue;
}
watchedActiveIds.delete(id);
if (isTerminalCancelled(job.status)) continue;
toastedTerminalIds.add(id);
const typeLabel = formatProcessingTypeLabel(job.processing_type ?? job.type);
const viewAction = { label: i18n.t("taskIndicator.view"), href: "/processing" };
if (isTerminalSuccess(job.status)) {
notifySuccess(i18n.t("toast.processing.jobCompleted"), {
alert: "ai_done",
description: typeLabel,
actions: [viewAction],
duration: 10000
});
continue;
}
if (isTerminalFailure(job.status)) {
const detail = formatJobErrorText(job.error);
notifyError(i18n.t("toast.processing.jobFailed"), {
alert: "ai_fail",
description: detail || typeLabel,
actions: [viewAction],
duration: 12000
});
}
}
if (toastedTerminalIds.size > 200) {
toastedTerminalIds.clear();
}
}
function syncDisplay(nextJobs: ProcessingJob[]) {
notifyTerminalTransitions(nextJobs);
const active = nextJobs.filter((j) => isActiveProcessingJob(j.status));
if (active.length > 0) {
displayJobs = active.slice(0, 3);
if (lingerTimer !== undefined) {
window.clearTimeout(lingerTimer);
lingerTimer = undefined;
}
return;
}
if (displayJobs.length === 0 || lingerTimer !== undefined) return;
lingerTimer = window.setTimeout(() => {
displayJobs = [];
lingerTimer = undefined;
}, LINGER_MS);
}
function jobProgress(job: ProcessingJob): { pct: number; done: number; total: number } {
const total = typeof job.total_products === "number" ? job.total_products : 0;
const done = typeof job.processed_products === "number" ? job.processed_products : 0;
if (!total) return { pct: 0, done, total: 0 };
return { pct: Math.min(100, Math.round((done / total) * 100)), done, total };
}
function jobTitle(job: ProcessingJob): string {
return formatProcessingTypeLabel(job.processing_type ?? job.type);
}
async function refresh(signal?: AbortSignal) {
const myGen = ++gen;
try {
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", {
signal
});
if (myGen !== gen) return true;
jobs = unwrapList(payload);
syncDisplay(jobs);
return true;
} catch (err) {
if (myGen !== gen) return true;
// Aborted fetches are normal on unmount / overlapping ticks — do not enter the
// slow retry backoff or the indicator stays blank across short-lived jobs.
const aborted =
signal?.aborted ||
(err instanceof DOMException && err.name === "AbortError") ||
(err instanceof Error && err.name === "AbortError");
return aborted;
}
}
function schedule(ms: number) {
if (timer !== undefined) window.clearInterval(timer);
timer = window.setInterval(() => {
void tick();
}, ms);
}
async function tick() {
if (document.visibilityState === "hidden") return;
abort?.abort();
const ac = new AbortController();
abort = ac;
const ok = await refresh(ac.signal);
if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS);
}
onMount(() => {
if (!browser) return;
const ac = new AbortController();
void (async () => {
const ok = await refresh(ac.signal);
if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS);
})();
const onVis = () => {
if (document.visibilityState === "visible") void tick();
};
document.addEventListener("visibilitychange", onVis);
const boot = window.setTimeout(() => {
if (!ac.signal.aborted) void tick();
}, 750);
return () => {
window.clearTimeout(boot);
if (lingerTimer !== undefined) window.clearTimeout(lingerTimer);
ac.abort();
abort?.abort();
if (timer !== undefined) window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVis);
};
});
</script>
{#if visible}
<div
class="pointer-events-none fixed bottom-4 right-4 z-40 flex w-[min(100vw-2rem,22rem)] flex-col items-stretch gap-2"
role="status"
aria-live="polite"
data-testid="task-status-indicator"
>
<div
class="pointer-events-auto overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/90"
>
<div class="flex items-start gap-2 border-b border-border px-3 py-2.5">
<Layers class="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{i18n.t("taskIndicator.title")}</p>
<p class="text-xs text-muted-foreground">{headline}</p>
</div>
<button
type="button"
class="rounded-md p-1 text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={!collapsed}
aria-label={collapsed ? i18n.t("taskIndicator.expand") : i18n.t("taskIndicator.collapse")}
onclick={() => (collapsed = !collapsed)}
>
{#if collapsed}
<ChevronUp class="h-3.5 w-3.5" aria-hidden="true" />
{:else}
<ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
{/if}
</button>
</div>
{#if !collapsed}
<ul class="divide-y divide-border">
{#each previewJobs as job (job.id)}
{@const { pct, done, total } = jobProgress(job)}
<li class="space-y-1.5 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-xs font-medium">{jobTitle(job)}</p>
<Badge variant="outline" class="shrink-0 capitalize">
{formatJobStatusLabel(job.status)}
</Badge>
</div>
{#if total > 0}
<Progress value={pct} class="" />
<p class="text-[11px] tabular-nums text-muted-foreground">
{i18n.t("taskIndicator.progressCounts", { done, total })}
</p>
<span class="sr-only">{i18n.t("taskIndicator.progressAria", { pct })}</span>
{/if}
</li>
{/each}
</ul>
<div class="border-t border-border px-3 py-2">
<a href="/processing" class={buttonClasses("outline", "sm", "w-full justify-center")}>
{i18n.t("taskIndicator.view")}
</a>
</div>
{:else}
<div class="px-3 py-2">
<a
href="/processing"
class="text-xs font-medium text-link underline-offset-4 hover:underline"
>
{i18n.t("taskIndicator.view")}
</a>
</div>
{/if}
</div>
</div>
{/if}