190 lines
5.4 KiB
TypeScript
190 lines
5.4 KiB
TypeScript
// PERMANENT: IndexedDB cache for jobs data
|
|
// Provides lightning-fast job listing with zero-delay loading
|
|
//
|
|
// Strategy:
|
|
// - Display cached jobs instantly on page load (0ms delay)
|
|
// - Fetch fresh data in background after displaying cache
|
|
// - Cache TTL: 5 minutes (configurable)
|
|
// - Stores minimal fields for card display only
|
|
//
|
|
// Fields cached: Job_Number, Job_Name, Division, Active, Status, Estimator
|
|
//
|
|
// Usage:
|
|
// await jobCache.initialize();
|
|
// const jobs = await jobCache.getCachedJobs(); // Instant
|
|
// await jobCache.refreshJobs(); // Background refresh
|
|
|
|
export interface JobCard {
|
|
Job_Number: string;
|
|
Job_Name: string;
|
|
Division: string;
|
|
Active: string;
|
|
Status: string;
|
|
Estimator: string;
|
|
}
|
|
|
|
interface CacheMetadata {
|
|
lastUpdated: number;
|
|
version: number;
|
|
}
|
|
|
|
const DB_NAME = 'JobInfoCache';
|
|
const DB_VERSION = 2; // bump to invalidate old minimal-field cache
|
|
const JOBS_STORE = 'jobs';
|
|
const META_STORE = 'metadata';
|
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
const PAGE_SIZE = 500; // match Mode1 bulk fetch
|
|
|
|
class JobCache {
|
|
private db: IDBDatabase | null = null;
|
|
|
|
async initialize(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
this.db = request.result;
|
|
resolve();
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
|
|
// Create jobs store with Job_Number as key
|
|
if (!db.objectStoreNames.contains(JOBS_STORE)) {
|
|
db.createObjectStore(JOBS_STORE, { keyPath: 'Job_Number' });
|
|
}
|
|
|
|
// Create metadata store
|
|
if (!db.objectStoreNames.contains(META_STORE)) {
|
|
db.createObjectStore(META_STORE);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
async getCachedJobs(): Promise<JobCard[]> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([JOBS_STORE], 'readonly');
|
|
const store = transaction.objectStore(JOBS_STORE);
|
|
const request = store.getAll();
|
|
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async isCacheValid(): Promise<boolean> {
|
|
if (!this.db) return false;
|
|
|
|
try {
|
|
const metadata = await this.getMetadata();
|
|
if (!metadata || !metadata.lastUpdated) return false;
|
|
|
|
const age = Date.now() - metadata.lastUpdated;
|
|
return age < CACHE_TTL;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async saveJobs(jobs: JobCard[]): Promise<void> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
|
const jobsStore = transaction.objectStore(JOBS_STORE);
|
|
const metaStore = transaction.objectStore(META_STORE);
|
|
|
|
// Clear existing jobs
|
|
jobsStore.clear();
|
|
|
|
// Save new jobs
|
|
jobs.forEach(job => jobsStore.add(job));
|
|
|
|
// Update metadata
|
|
const metadata: CacheMetadata = {
|
|
lastUpdated: Date.now(),
|
|
version: DB_VERSION
|
|
};
|
|
metaStore.put(metadata, 'main');
|
|
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
}
|
|
|
|
async refreshJobs(): Promise<JobCard[]> {
|
|
try {
|
|
// Fetch fresh data from backend with pagination to mirror Mode1 behavior
|
|
let page = 1;
|
|
let all: JobCard[] = [];
|
|
let totalPages = 1;
|
|
|
|
while (page <= totalPages) {
|
|
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch jobs (status ${response.status})`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const items: JobCard[] = data.items || [];
|
|
const reportedTotalPages = data.totalPages || 1;
|
|
|
|
all = all.concat(items);
|
|
totalPages = Math.max(totalPages, reportedTotalPages);
|
|
page += 1;
|
|
}
|
|
|
|
// Save to cache
|
|
await this.saveJobs(all);
|
|
|
|
return all;
|
|
} catch (err) {
|
|
console.error('Failed to refresh jobs cache:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async getMetadata(): Promise<CacheMetadata | null> {
|
|
if (!this.db) return null;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([META_STORE], 'readonly');
|
|
const store = transaction.objectStore(META_STORE);
|
|
const request = store.get('main');
|
|
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async clearCache(): Promise<void> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
|
|
|
|
transaction.objectStore(JOBS_STORE).clear();
|
|
transaction.objectStore(META_STORE).clear();
|
|
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
}
|
|
|
|
async getCacheAge(): Promise<number | null> {
|
|
const metadata = await this.getMetadata();
|
|
if (!metadata || !metadata.lastUpdated) return null;
|
|
|
|
return Date.now() - metadata.lastUpdated;
|
|
}
|
|
}
|
|
|
|
export const jobCache = new JobCache();
|