From ee66b6b2356af1439089c888e2fc660e052ab9be Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 1 Apr 2026 07:09:55 -0700 Subject: [PATCH] feat(web): add task indicator component and task store for background jobs - web/src/stores/tasks.ts: Pinia store polling /api/tasks/active - web/src/components/TaskIndicator.vue: sidebar + mobile task queue display with live count badge - web/public/: peregrine logo assets (SVG + PNG variants) --- web/public/peregrine.svg | 165 +++++++++++++++++++++ web/src/components/TaskIndicator.vue | 205 +++++++++++++++++++++++++++ web/src/stores/tasks.ts | 101 +++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 web/public/peregrine.svg create mode 100644 web/src/components/TaskIndicator.vue create mode 100644 web/src/stores/tasks.ts diff --git a/web/public/peregrine.svg b/web/public/peregrine.svg new file mode 100644 index 0000000..7653d13 --- /dev/null +++ b/web/public/peregrine.svg @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/components/TaskIndicator.vue b/web/src/components/TaskIndicator.vue new file mode 100644 index 0000000..ec88b8a --- /dev/null +++ b/web/src/components/TaskIndicator.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/web/src/stores/tasks.ts b/web/src/stores/tasks.ts new file mode 100644 index 0000000..2d8f782 --- /dev/null +++ b/web/src/stores/tasks.ts @@ -0,0 +1,101 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../composables/useApi' + +export interface ActiveTask { + id: number + task_type: string + job_id: number + status: 'running' | 'queued' +} + +export const TASK_LABEL: Record = { + cover_letter: 'Cover letter', + company_research: 'Research', + discovery: 'Discovery', + enrich_descriptions: 'Enriching descriptions', + score: 'Scoring matches', + scrape_url: 'Scraping listing', + email_sync: 'Email sync', + wizard_generate: 'Wizard', + prepare_training: 'Training data', +} + +/** + * Ordered pipeline stages — tasks are visually grouped under discovery + * when they appear together, showing users the full auto-chain. + */ +export const DISCOVERY_PIPELINE = ['discovery', 'enrich_descriptions', 'score'] as const + +/** Group active tasks into pipeline groups for display. + * Non-pipeline tasks (cover_letter, email_sync, etc.) each form their own group. + */ +export interface TaskGroup { + primary: ActiveTask + steps: ActiveTask[] // pipeline children, empty for non-pipeline tasks +} + +export function groupTasks(tasks: ActiveTask[]): TaskGroup[] { + const pipelineSet = new Set(DISCOVERY_PIPELINE as readonly string[]) + const pipelineTasks = tasks.filter(t => pipelineSet.has(t.task_type)) + const otherTasks = tasks.filter(t => !pipelineSet.has(t.task_type)) + + const groups: TaskGroup[] = [] + + // Build one discovery pipeline group from all pipeline tasks in order + if (pipelineTasks.length) { + const ordered = [...DISCOVERY_PIPELINE] + .map(type => pipelineTasks.find(t => t.task_type === type)) + .filter(Boolean) as ActiveTask[] + groups.push({ primary: ordered[0], steps: ordered.slice(1) }) + } + + // Each non-pipeline task is its own group + for (const task of otherTasks) { + groups.push({ primary: task, steps: [] }) + } + + return groups +} + +export const useTasksStore = defineStore('tasks', () => { + const tasks = ref([]) + const count = computed(() => tasks.value.length) + const groups = computed(() => groupTasks(tasks.value)) + const label = computed(() => { + if (!tasks.value.length) return '' + const first = tasks.value[0] + const name = TASK_LABEL[first.task_type] ?? first.task_type + return tasks.value.length === 1 ? name : `${name} +${tasks.value.length - 1}` + }) + + // Callback registered by views that want counts refreshed while tasks run + let _onTasksClear: (() => void) | null = null + let _tasksWereActive = false + + function onTasksClear(cb: () => void) { _onTasksClear = cb } + + let _timer: ReturnType | null = null + + async function poll() { + const { data } = await useApiFetch<{ count: number; tasks: ActiveTask[] }>('/api/tasks/active') + if (!data) return + const wasActive = _tasksWereActive + tasks.value = data.tasks + _tasksWereActive = data.tasks.length > 0 + // Fire callback when task queue just cleared so counts can update + if (wasActive && !_tasksWereActive && _onTasksClear) _onTasksClear() + } + + function startPolling() { + if (_timer) return + poll() + _timer = setInterval(poll, 4000) + } + + function stopPolling() { + if (_timer) { clearInterval(_timer); _timer = null } + } + + return { tasks, count, groups, label, poll, startPolling, stopPolling, onTasksClear } +})