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)
This commit is contained in:
parent
02e004ee5c
commit
ee66b6b235
3 changed files with 471 additions and 0 deletions
165
web/public/peregrine.svg
Normal file
165
web/public/peregrine.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 106 KiB |
205
web/src/components/TaskIndicator.vue
Normal file
205
web/src/components/TaskIndicator.vue
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
<template>
|
||||||
|
<!-- Desktop: inline queue in sidebar footer -->
|
||||||
|
<div v-if="count > 0" class="task-indicator task-indicator--sidebar" aria-live="polite" role="status">
|
||||||
|
<template v-for="group in groups" :key="group.primary.id">
|
||||||
|
<!-- Primary task row -->
|
||||||
|
<div class="task-row task-row--primary">
|
||||||
|
<span class="task-row__spinner" :class="`task-row__spinner--${group.primary.status}`" aria-hidden="true" />
|
||||||
|
<span class="task-row__label">{{ TASK_LABEL[group.primary.task_type] ?? group.primary.task_type }}</span>
|
||||||
|
<span class="task-row__status">{{ group.primary.status }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Pipeline sub-steps (indented) -->
|
||||||
|
<div
|
||||||
|
v-for="step in group.steps"
|
||||||
|
:key="step.id"
|
||||||
|
class="task-row task-row--step"
|
||||||
|
:class="`task-row--${step.status}`"
|
||||||
|
>
|
||||||
|
<span class="task-row__indent" aria-hidden="true">↳</span>
|
||||||
|
<span class="task-row__spinner" :class="`task-row__spinner--${step.status}`" aria-hidden="true" />
|
||||||
|
<span class="task-row__label">{{ TASK_LABEL[step.task_type] ?? step.task_type }}</span>
|
||||||
|
<span class="task-row__status">{{ step.status }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: fixed pill above bottom tab bar (compact — keeps existing design) -->
|
||||||
|
<Transition name="task-pill">
|
||||||
|
<div
|
||||||
|
v-if="count > 0"
|
||||||
|
class="task-indicator task-indicator--pill"
|
||||||
|
aria-live="polite"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="task-indicator__spinner" aria-hidden="true" />
|
||||||
|
<span class="task-indicator__label">{{ label }}</span>
|
||||||
|
<span class="task-indicator__badge">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useTasksStore, TASK_LABEL } from '../stores/tasks'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
const store = useTasksStore()
|
||||||
|
const { count, groups, label } = storeToRefs(store)
|
||||||
|
|
||||||
|
onMounted(store.startPolling)
|
||||||
|
onUnmounted(store.stopPolling)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Shared ─────────────────────────────────────────── */
|
||||||
|
.task-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner — CSS-only rotating ring */
|
||||||
|
.task-indicator__spinner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: task-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes task-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-indicator__label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-indicator__badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop sidebar variant — shown by the sidebar, hidden on mobile ── */
|
||||||
|
.task-indicator--sidebar {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task rows ─────────────────────────────────────── */
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--primary { padding: var(--space-1) 0; }
|
||||||
|
|
||||||
|
.task-row--step {
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--queued { opacity: 0.5; }
|
||||||
|
|
||||||
|
.task-row__indent {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row__spinner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row__spinner--running {
|
||||||
|
border: 1.5px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
|
||||||
|
border-top-color: var(--app-primary);
|
||||||
|
animation: task-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row__spinner--queued {
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row__label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row__status {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile pill variant — fixed above tab bar ─────── */
|
||||||
|
.task-indicator--pill {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-2));
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
/* hidden on desktop, shown on mobile */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────── */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.task-indicator--sidebar { display: none; }
|
||||||
|
.task-indicator--pill { display: flex; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.task-indicator--pill { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transition (pill slide-up) ─────────────────────── */
|
||||||
|
.task-pill-enter-active,
|
||||||
|
.task-pill-leave-active {
|
||||||
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
|
}
|
||||||
|
.task-pill-enter-from,
|
||||||
|
.task-pill-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
web/src/stores/tasks.ts
Normal file
101
web/src/stores/tasks.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<ActiveTask[]>([])
|
||||||
|
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<typeof setInterval> | 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 }
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue