npm package: @geepers/jobs
Description: Framework-agnostic async job tracking with polling and localStorage persistence
Author: Luke Steuber
Latest version: 1.0.0

# @geepers/jobs

[![npm version](https://img.shields.io/npm/v/@geepers/jobs.svg)](https://www.npmjs.com/package/@geepers/jobs)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Framework-agnostic async job tracking with built-in polling, EventEmitter callbacks, and optional localStorage persistence. Works in any environment — browser, Node, Deno.

Extracted from the geepers-chat job system. Designed for long-running tasks like image generation, video rendering, TTS synthesis, or any async operation you need to poll for.

## Install

```bash
npm install @geepers/jobs
```

## Quick start

```ts
import { JobManager, generateJobId } from '@geepers/jobs';

const manager = new JobManager({
  storageKey: 'my-app-jobs', // persist active jobs to localStorage (browser only)
});

// Listen for completions
manager.onJobComplete(job => {
  if (job.status === 'done') {
    console.log('Done!', job);
  } else {
    console.error('Failed:', job.error);
  }
});

// Add a job
const job = manager.addJob({
  id: generateJobId(),
  type: 'image',
  status: 'pending',
  prompt: 'A sunset over mountains',
  startedAt: Date.now(),
});

// Start polling
manager.startPolling(job.id, {
  activeInterval: 3000,  // poll every 3s when visible
  hiddenInterval: 15000, // poll every 15s when tab is hidden
  pollFn: async (j) => {
    const res = await fetch(`/api/jobs/${j.id}/status`);
    const data = await res.json();
    return {
      status: data.status,           // 'pending' | 'processing' | 'done' | 'failed'
      result: { url: data.imageUrl }, // merged into job on completion
    };
  },
});
```

## API

### `new JobManager(options?)`

| Option | Type | Description |
|--------|------|-------------|
| `storageKey` | `string` | localStorage key for persisting active jobs. Omit to disable. |
| `persistFilter` | `(job) => boolean` | Which jobs to persist. Default: pending or processing jobs. |
| `defaultPollConfig` | `object` | Default `activeInterval`, `hiddenInterval`, `errorInterval`. |
| `onNotify` | `(msg, level) => void` | Optional hook for completion notifications. |

### Core methods

```ts
manager.addJob(job: Job): Job
manager.updateJob(id: string, updates: Partial<Job>): Job | undefined
manager.removeJob(id: string): void
manager.getJob(id: string): Job | undefined
manager.getJobs(type?: JobType): Job[]
manager.getActiveCount(type?: JobType): number
manager.clearJobs(type?: JobType): void
```

### Polling

```ts
manager.startPolling(jobId: string, config: PollConfig): void
manager.stopPolling(pollKey: string): void
manager.stopAllPolling(): void
```

`PollConfig`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `pollFn` | `(job) => Promise<PollResult>` | required | Fetches current job status |
| `activeInterval` | `number` | `5000` | Interval (ms) when tab is visible |
| `hiddenInterval` | `number` | `15000` | Interval (ms) when tab is hidden |
| `errorInterval` | `number` | `10000` | Interval (ms) after a poll error |

`PollResult`:

```ts
interface PollResult {
  status: string; // 'pending' | 'processing' | 'done' | 'failed' | 'error' | 'expired'
  result?: Partial<Job>; // merged into the job on terminal status
  error?: string;
}
```

### Events

```ts
manager.onJobComplete(callback: (job: Job) => void): () => void
manager.onJobUpdate(callback: (job: Job) => void): () => void

// Raw EventEmitter events:
// 'job:added'    (job)
// 'job:updated'  (job)
// 'job:complete' (job)
// 'job:removed'  (id)
// 'jobs:cleared' (type?)
```

Both `onJobComplete` and `onJobUpdate` return an unsubscribe function.

### Helpers

```ts
import { createJobManager, generateJobId } from '@geepers/jobs';

const manager = createJobManager({ storageKey: 'my-jobs' });
const id = generateJobId(); // uses crypto.randomUUID() or timestamp fallback
```

### Cleanup

```ts
manager.destroy(); // stop all polling, remove all listeners, clear jobs
```

## Built-in job types

The package ships typed interfaces for common use cases:

```ts
type JobType = 'image' | 'video' | 'tts' | 'research' | (string & {});

interface ImageJob extends BaseJob { type: 'image'; url?: string; revised_prompt?: string; }
interface VideoJob extends BaseJob { type: 'video'; requestId: string; videoUrl?: string; }
interface TTSJob   extends BaseJob { type: 'tts';   audioUrl?: string; voice?: string; }
interface ResearchJob extends BaseJob { type: 'research'; reportUrl?: string; agentCount?: number; }
```

You can use any custom string as a type — the types are open-ended.

## React usage

This package has no React dependency, but wrapping it in a context is straightforward:

```ts
import { useEffect, useRef } from 'react';
import { JobManager } from '@geepers/jobs';

const managerRef = useRef(new JobManager({ storageKey: 'my-app-jobs' }));

useEffect(() => {
  return () => managerRef.current.destroy();
}, []);
```

## License

MIT — [Luke Steuber](https://lukesteuber.com)