diff --git a/backend/src/services/task-scheduler.ts b/backend/src/services/task-scheduler.ts index 2c59e411..08db2e0e 100644 --- a/backend/src/services/task-scheduler.ts +++ b/backend/src/services/task-scheduler.ts @@ -323,9 +323,13 @@ class TaskScheduler { } // Group dispensaries by platform (menu_type) + // Map 'embedded' to 'dutchie' since embedded is just a menu display type, not a platform const byPlatform: Record = {}; for (const d of dispensaries) { - const platform = d.menu_type || 'dutchie'; + let platform = d.menu_type || 'dutchie'; + if (platform === 'embedded') { + platform = 'dutchie'; + } if (!byPlatform[platform]) { byPlatform[platform] = []; } @@ -333,6 +337,7 @@ class TaskScheduler { } // Create tasks per platform so each task routes to the correct handler + // Use schedule.interval_hours as skipRecentHours to allow re-runs at schedule frequency let totalCreated = 0; for (const [platform, ids] of Object.entries(byPlatform)) { if (ids.length > 0) { @@ -346,6 +351,7 @@ class TaskScheduler { { source: 'schedule', source_schedule_id: schedule.id, + skipRecentHours: schedule.interval_hours, // Match schedule frequency } ); totalCreated += created; diff --git a/backend/src/tasks/handlers/product-refresh.ts b/backend/src/tasks/handlers/product-refresh.ts index acf037f6..fd4ac594 100644 --- a/backend/src/tasks/handlers/product-refresh.ts +++ b/backend/src/tasks/handlers/product-refresh.ts @@ -241,19 +241,9 @@ export async function handleProductRefresh(ctx: TaskContext): Promise { - let compressed: Buffer; + let fileBuffer: Buffer; // Determine if path looks like MinIO key (starts with payloads/) or local path const isMinIOPath = storagePath.startsWith('payloads/') && useMinIO; @@ -298,14 +297,19 @@ export async function loadPayloadFromPath(storagePath: string): Promise { for await (const chunk of stream) { chunks.push(chunk as Buffer); } - compressed = Buffer.concat(chunks); + fileBuffer = Buffer.concat(chunks); } else { // Read from local filesystem - compressed = await fs.promises.readFile(storagePath); + fileBuffer = await fs.promises.readFile(storagePath); } - const decompressed = await gunzip(compressed); - return JSON.parse(decompressed.toString('utf8')); + // Handle both compressed (.gz) and uncompressed (.json) files + if (storagePath.endsWith('.gz')) { + const decompressed = await gunzip(fileBuffer); + return JSON.parse(decompressed.toString('utf8')); + } else { + return JSON.parse(fileBuffer.toString('utf8')); + } } /** @@ -490,12 +494,13 @@ function generateDiscoveryStoragePath(stateCode: string, timestamp: Date): strin const day = String(timestamp.getDate()).padStart(2, '0'); const ts = timestamp.getTime(); - const relativePath = `payloads/discovery/${year}/${month}/${day}/state_${stateCode.toLowerCase()}_${ts}.json.gz`; + // Compression disabled - store as plain JSON + const relativePath = `payloads/discovery/${year}/${month}/${day}/state_${stateCode.toLowerCase()}_${ts}.json`; if (useMinIO) { return relativePath; } else { - return path.join(PAYLOAD_BASE_PATH, 'discovery', String(year), month, day, `state_${stateCode.toLowerCase()}_${ts}.json.gz`); + return path.join(PAYLOAD_BASE_PATH, 'discovery', String(year), month, day, `state_${stateCode.toLowerCase()}_${ts}.json`); } } @@ -517,28 +522,26 @@ export async function saveDiscoveryPayload( const timestamp = new Date(); const storagePath = generateDiscoveryStoragePath(stateCode, timestamp); - // Serialize and compress - const jsonStr = JSON.stringify(payload); + // Serialize as plain JSON (compression disabled for easier debugging) + const jsonStr = JSON.stringify(payload, null, 2); const rawSize = Buffer.byteLength(jsonStr, 'utf8'); - const compressed = await gzip(Buffer.from(jsonStr, 'utf8')); - const compressedSize = compressed.length; - const checksum = calculateChecksum(compressed); + const jsonBuffer = Buffer.from(jsonStr, 'utf8'); + const checksum = calculateChecksum(jsonBuffer); // Write to storage backend if (useMinIO) { // Upload to MinIO const client = getMinioClient(); - await client.putObject(MINIO_BUCKET, storagePath, compressed, compressedSize, { - 'Content-Type': 'application/gzip', - 'Content-Encoding': 'gzip', + await client.putObject(MINIO_BUCKET, storagePath, jsonBuffer, rawSize, { + 'Content-Type': 'application/json', }); } else { // Write to local filesystem await ensureDir(storagePath); - await fs.promises.writeFile(storagePath, compressed); + await fs.promises.writeFile(storagePath, jsonBuffer); } - // Record metadata in DB + // Record metadata in DB (size_bytes = size_bytes_raw since no compression) const result = await pool.query(` INSERT INTO raw_crawl_payloads ( payload_type, @@ -556,19 +559,19 @@ export async function saveDiscoveryPayload( stateCode.toUpperCase(), storagePath, storeCount, - compressedSize, + rawSize, rawSize, timestamp, checksum ]); const backend = useMinIO ? 'MinIO' : 'local'; - console.log(`[PayloadStorage] Saved discovery payload to ${backend} for ${stateCode}: ${storagePath} (${storeCount} stores, ${(compressedSize / 1024).toFixed(1)}KB compressed)`); + console.log(`[PayloadStorage] Saved discovery payload to ${backend} for ${stateCode}: ${storagePath} (${storeCount} stores, ${(rawSize / 1024).toFixed(1)}KB)`); return { id: result.rows[0].id, storagePath, - sizeBytes: compressedSize, + sizeBytes: rawSize, sizeBytesRaw: rawSize, checksum }; diff --git a/cannaiq/src/App.tsx b/cannaiq/src/App.tsx index bd292159..0370291b 100755 --- a/cannaiq/src/App.tsx +++ b/cannaiq/src/App.tsx @@ -49,6 +49,7 @@ import { Discovery } from './pages/Discovery'; import { WorkersDashboard } from './pages/WorkersDashboard'; import { ProxyManagement } from './pages/ProxyManagement'; import TasksDashboard from './pages/TasksDashboard'; +import { PayloadsDashboard } from './pages/PayloadsDashboard'; import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard'; import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator'; import { StatePage } from './pages/public/StatePage'; @@ -131,6 +132,7 @@ export default function App() { } /> {/* Task Queue Dashboard */} } /> + } /> {/* Scraper Overview Dashboard (new primary) */} } /> } /> diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 5dd8a06c..3fba5261 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -3128,6 +3128,70 @@ class ApiClient { { method: 'POST' } ); } + + // ============================================================ + // PAYLOADS API + // ============================================================ + + async getPayloads(params?: { limit?: number; offset?: number; dispensary_id?: number }) { + const searchParams = new URLSearchParams(); + if (params?.limit) searchParams.append('limit', String(params.limit)); + if (params?.offset) searchParams.append('offset', String(params.offset)); + if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id)); + const query = searchParams.toString(); + return this.request<{ + success: boolean; + payloads: PayloadMetadata[]; + pagination: { limit: number; offset: number }; + }>(`/api/payloads${query ? '?' + query : ''}`); + } + + async getPayload(id: number) { + return this.request<{ + success: boolean; + payload: PayloadMetadata & { dispensary_name: string }; + }>(`/api/payloads/${id}`); + } + + async getPayloadData(id: number) { + return this.request<{ + success: boolean; + metadata: { id: number; dispensaryId: number; productCount: number; fetchedAt: string; storagePath: string }; + data: any; + }>(`/api/payloads/${id}/data`); + } + + async getStorePayloads(dispensaryId: number, params?: { limit?: number; offset?: number }) { + const searchParams = new URLSearchParams(); + if (params?.limit) searchParams.append('limit', String(params.limit)); + if (params?.offset) searchParams.append('offset', String(params.offset)); + const query = searchParams.toString(); + return this.request<{ + success: boolean; + dispensaryId: number; + payloads: PayloadMetadata[]; + pagination: { limit: number; offset: number }; + }>(`/api/payloads/store/${dispensaryId}${query ? '?' + query : ''}`); + } + + async getPayloadDiff(dispensaryId: number, fromId?: number, toId?: number) { + const searchParams = new URLSearchParams(); + if (fromId) searchParams.append('from', String(fromId)); + if (toId) searchParams.append('to', String(toId)); + const query = searchParams.toString(); + return this.request<{ + success: boolean; + from: { id: number; fetchedAt: string; productCount: number }; + to: { id: number; fetchedAt: string; productCount: number }; + diff: { added: number; removed: number; priceChanges: number; stockChanges: number }; + details: { + added: Array<{ id: string; name: string; brand?: string; price?: number }>; + removed: Array<{ id: string; name: string; brand?: string; price?: number }>; + priceChanges: Array<{ id: string; name: string; brand?: string; oldPrice: number; newPrice: number; change: number }>; + stockChanges: Array<{ id: string; name: string; brand?: string; oldStatus: string; newStatus: string }>; + }; + }>(`/api/payloads/store/${dispensaryId}/diff${query ? '?' + query : ''}`); + } } // Type for task schedules @@ -3155,4 +3219,17 @@ export interface TaskSchedule { updated_at: string; } +// Type for payload metadata +export interface PayloadMetadata { + id: number; + dispensaryId: number; + crawlRunId: number | null; + storagePath: string; + productCount: number; + sizeBytes: number; + sizeBytesRaw: number; + fetchedAt: string; + dispensary_name?: string; +} + export const api = new ApiClient(API_URL); diff --git a/cannaiq/src/pages/PayloadsDashboard.tsx b/cannaiq/src/pages/PayloadsDashboard.tsx new file mode 100644 index 00000000..24e5b1b6 --- /dev/null +++ b/cannaiq/src/pages/PayloadsDashboard.tsx @@ -0,0 +1,611 @@ +import { useState, useEffect } from 'react'; +import { api, PayloadMetadata } from '../lib/api'; +import { Layout } from '../components/Layout'; +import { + Database, + Search, + ChevronLeft, + ChevronRight, + FileJson, + Clock, + Store, + Package, + HardDrive, + Eye, + X, + RefreshCw, + ArrowUpDown, + ArrowUp, + ArrowDown, + GitCompare, +} from 'lucide-react'; + +interface Store { + id: number; + name: string; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function formatTimeAgo(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function PayloadsDashboard() { + const [payloads, setPayloads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(0); + const [limit] = useState(25); + const [totalCount, setTotalCount] = useState(0); + const [search, setSearch] = useState(''); + const [stores, setStores] = useState([]); + const [selectedStoreId, setSelectedStoreId] = useState(null); + const [storeSearch, setStoreSearch] = useState(''); + const [showStoreDropdown, setShowStoreDropdown] = useState(false); + + // View modal + const [viewingPayload, setViewingPayload] = useState(null); + const [payloadData, setPayloadData] = useState(null); + const [loadingPayload, setLoadingPayload] = useState(false); + + // Diff modal + const [diffing, setDiffing] = useState(false); + const [diffResult, setDiffResult] = useState(null); + const [diffStoreId, setDiffStoreId] = useState(null); + + // Sorting + const [sortField, setSortField] = useState<'fetchedAt' | 'productCount' | 'sizeBytes'>('fetchedAt'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + + useEffect(() => { + fetchPayloads(); + }, [page, selectedStoreId]); + + useEffect(() => { + fetchStores(); + }, []); + + const fetchPayloads = async () => { + try { + setLoading(true); + setError(null); + const result = await api.getPayloads({ + limit, + offset: page * limit, + dispensary_id: selectedStoreId || undefined, + }); + setPayloads(result.payloads); + // Estimate total - if we got a full page, there's probably more + if (result.payloads.length === limit) { + setTotalCount((page + 2) * limit); + } else { + setTotalCount(page * limit + result.payloads.length); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const fetchStores = async () => { + try { + const result = await api.get<{ dispensaries: Store[] }>('/api/dispensaries', { + params: { limit: 500 } + }); + setStores(result.data.dispensaries || []); + } catch (err) { + console.error('Failed to fetch stores:', err); + } + }; + + const viewPayload = async (id: number) => { + setViewingPayload(id); + setLoadingPayload(true); + try { + const result = await api.getPayloadData(id); + setPayloadData(result); + } catch (err: any) { + setError(err.message); + } finally { + setLoadingPayload(false); + } + }; + + const viewDiff = async (dispensaryId: number) => { + setDiffStoreId(dispensaryId); + setDiffing(true); + try { + const result = await api.getPayloadDiff(dispensaryId); + setDiffResult(result); + } catch (err: any) { + setError(err.message); + setDiffing(false); + } + }; + + const closeDiff = () => { + setDiffing(false); + setDiffResult(null); + setDiffStoreId(null); + }; + + const filteredStores = stores.filter(s => + s.name.toLowerCase().includes(storeSearch.toLowerCase()) + ); + + const sortedPayloads = [...payloads].sort((a, b) => { + let aVal: any, bVal: any; + switch (sortField) { + case 'fetchedAt': + aVal = new Date(a.fetchedAt).getTime(); + bVal = new Date(b.fetchedAt).getTime(); + break; + case 'productCount': + aVal = a.productCount; + bVal = b.productCount; + break; + case 'sizeBytes': + aVal = a.sizeBytes; + bVal = b.sizeBytes; + break; + } + return sortDir === 'asc' ? aVal - bVal : bVal - aVal; + }); + + const handleSort = (field: typeof sortField) => { + if (sortField === field) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDir('desc'); + } + }; + + const SortIcon = ({ field }: { field: typeof sortField }) => { + if (sortField !== field) return ; + return sortDir === 'asc' ? : ; + }; + + return ( + +
+ {/* Header */} +
+
+ +
+

Payloads

+

Raw crawl data stored in MinIO

+
+
+ +
+ + {/* Filters */} +
+
+ {/* Store Filter */} +
+ +
+ + {showStoreDropdown && ( +
+
+ setStoreSearch(e.target.value)} + className="w-full px-3 py-2 border rounded text-sm" + autoFocus + /> +
+
{ + setSelectedStoreId(null); + setShowStoreDropdown(false); + setPage(0); + }} + className="px-3 py-2 hover:bg-gray-50 cursor-pointer text-sm" + > + All Stores +
+ {filteredStores.slice(0, 50).map(store => ( +
{ + setSelectedStoreId(store.id); + setShowStoreDropdown(false); + setPage(0); + }} + className={`px-3 py-2 hover:bg-gray-50 cursor-pointer text-sm ${ + selectedStoreId === store.id ? 'bg-purple-50 text-purple-700' : '' + }`} + > + {store.name} +
+ ))} +
+ )} +
+
+ + {/* Search - future */} +
+ +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm" + /> +
+
+
+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : sortedPayloads.length === 0 ? ( + + + + ) : ( + sortedPayloads.map((payload) => ( + + + + + + + + + + )) + )} + +
IDStore handleSort('fetchedAt')} + > +
+ Fetched +
+
handleSort('productCount')} + > +
+ Products +
+
handleSort('sizeBytes')} + > +
+ Size +
+
PathActions
+ + Loading payloads... +
+ No payloads found +
+ #{payload.id} + +
+ + + {payload.dispensary_name || `Store #${payload.dispensaryId}`} + +
+
+
+ + + {formatTimeAgo(payload.fetchedAt)} + +
+
+
+ + {payload.productCount.toLocaleString()} +
+
+
+ + {formatBytes(payload.sizeBytes)} +
+
+ + {payload.storagePath.split('/').slice(-1)[0]} + + +
+ + +
+
+ + {/* Pagination */} +
+
+ Showing {page * limit + 1} - {page * limit + payloads.length} of ~{totalCount} +
+
+ + Page {page + 1} + +
+
+
+ + {/* View Payload Modal */} + {viewingPayload && ( +
+
+
+
+ +

Payload #{viewingPayload}

+
+ +
+
+ {loadingPayload ? ( +
+ +
+ ) : payloadData ? ( +
+
+
+
+ Store: + #{payloadData.metadata.dispensaryId} +
+
+ Products: + {payloadData.metadata.productCount} +
+
+ Fetched: + {new Date(payloadData.metadata.fetchedAt).toLocaleString()} +
+
+ Path: + {payloadData.metadata.storagePath} +
+
+
+
+                      {JSON.stringify(payloadData.data, null, 2)}
+                    
+
+ ) : ( +
Failed to load payload
+ )} +
+
+
+ )} + + {/* Diff Modal */} + {diffing && ( +
+
+
+
+ +

Payload Diff - Store #{diffStoreId}

+
+ +
+
+ {!diffResult ? ( +
+ +
+ ) : ( +
+ {/* Summary */} +
+
+
From (Previous)
+
Payload #{diffResult.from.id}
+
{new Date(diffResult.from.fetchedAt).toLocaleString()}
+
{diffResult.from.productCount} products
+
+
+
To (Latest)
+
Payload #{diffResult.to.id}
+
{new Date(diffResult.to.fetchedAt).toLocaleString()}
+
{diffResult.to.productCount} products
+
+
+ + {/* Diff Stats */} +
+
+
{diffResult.diff.added}
+
Added
+
+
+
{diffResult.diff.removed}
+
Removed
+
+
+
{diffResult.diff.priceChanges}
+
Price Changes
+
+
+
{diffResult.diff.stockChanges}
+
Stock Changes
+
+
+ + {/* Details */} + {diffResult.details.added.length > 0 && ( +
+

Added Products ({diffResult.details.added.length})

+
+ {diffResult.details.added.slice(0, 20).map((p: any, i: number) => ( +
+ {p.name} + {p.brand && ({p.brand})} + {p.price && ${p.price}} +
+ ))} + {diffResult.details.added.length > 20 && ( +
...and {diffResult.details.added.length - 20} more
+ )} +
+
+ )} + + {diffResult.details.removed.length > 0 && ( +
+

Removed Products ({diffResult.details.removed.length})

+
+ {diffResult.details.removed.slice(0, 20).map((p: any, i: number) => ( +
+ {p.name} + {p.brand && ({p.brand})} +
+ ))} + {diffResult.details.removed.length > 20 && ( +
...and {diffResult.details.removed.length - 20} more
+ )} +
+
+ )} + + {diffResult.details.priceChanges.length > 0 && ( +
+

Price Changes ({diffResult.details.priceChanges.length})

+
+ {diffResult.details.priceChanges.slice(0, 20).map((p: any, i: number) => ( +
+ {p.name} + ${p.oldPrice} + + 0 ? 'text-red-600' : 'text-green-600'}> + ${p.newPrice} ({p.change > 0 ? '+' : ''}{p.change?.toFixed(2)}) + +
+ ))} + {diffResult.details.priceChanges.length > 20 && ( +
...and {diffResult.details.priceChanges.length - 20} more
+ )} +
+
+ )} +
+ )} +
+
+
+ )} +
+
+ ); +} + +export default PayloadsDashboard; diff --git a/cannaiq/src/pages/TasksDashboard.tsx b/cannaiq/src/pages/TasksDashboard.tsx index fa200fa5..27783731 100644 --- a/cannaiq/src/pages/TasksDashboard.tsx +++ b/cannaiq/src/pages/TasksDashboard.tsx @@ -106,15 +106,14 @@ interface CreateTaskModalProps { } const TASK_ROLES = [ - { id: 'product_refresh', name: 'Product Resync', description: 'Re-crawl products for price/stock changes' }, - { id: 'product_discovery', name: 'Product Discovery', description: 'Initial crawl for new dispensaries' }, + { id: 'product_discovery', name: 'Product Discovery', description: 'Crawl products from dispensary menu' }, { id: 'store_discovery', name: 'Store Discovery', description: 'Discover new dispensary locations' }, { id: 'entry_point_discovery', name: 'Entry Point Discovery', description: 'Resolve platform IDs from menu URLs' }, { id: 'analytics_refresh', name: 'Analytics Refresh', description: 'Refresh materialized views' }, ]; function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProps) { - const [role, setRole] = useState('product_refresh'); + const [role, setRole] = useState('product_discovery'); const [priority, setPriority] = useState(10); const [scheduleType, setScheduleType] = useState<'now' | 'scheduled'>('now'); const [scheduledFor, setScheduledFor] = useState(''); @@ -588,7 +587,7 @@ interface ScheduleEditModalProps { function ScheduleEditModal({ isOpen, schedule, onClose, onSave }: ScheduleEditModalProps) { const [name, setName] = useState(''); - const [role, setRole] = useState('product_refresh'); + const [role, setRole] = useState('product_discovery'); const [description, setDescription] = useState(''); const [enabled, setEnabled] = useState(true); const [intervalHours, setIntervalHours] = useState(4); @@ -622,7 +621,7 @@ function ScheduleEditModal({ isOpen, schedule, onClose, onSave }: ScheduleEditMo } else { // Reset for new schedule setName(''); - setRole('product_refresh'); + setRole('product_discovery'); setDescription(''); setEnabled(true); setIntervalHours(4); @@ -974,8 +973,8 @@ export default function TasksDashboard() { const [showCapacity, setShowCapacity] = useState(true); // Sorting - const [sortColumn, setSortColumn] = useState('created_at'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [sortColumn, setSortColumn] = useState('status'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); // asc = pending first // Pools for filter dropdown const [pools, setPools] = useState([]); @@ -1168,7 +1167,16 @@ export default function TasksDashboard() { return true; }); - // Sorting + // Sorting - default sort by status priority (pending, claimed/running, completed/failed) + // Within each status group, sort by newest first + const statusPriority: Record = { + pending: 0, + claimed: 1, + running: 1, + completed: 2, + failed: 3, + }; + const sortedTasks = [...filteredTasks].sort((a, b) => { const dir = sortDirection === 'asc' ? 1 : -1; switch (sortColumn) { @@ -1179,7 +1187,16 @@ export default function TasksDashboard() { case 'store': return (a.dispensary_name || '').localeCompare(b.dispensary_name || '') * dir; case 'status': - return a.status.localeCompare(b.status) * dir; + // Sort by status priority, then by time within each group + const aPriority = statusPriority[a.status] ?? 99; + const bPriority = statusPriority[b.status] ?? 99; + if (aPriority !== bPriority) { + return (aPriority - bPriority) * dir; + } + // Within same status, sort by newest first (created_at for pending, completed_at for completed) + const aTime = a.status === 'completed' ? new Date(a.completed_at || 0).getTime() : new Date(a.created_at).getTime(); + const bTime = b.status === 'completed' ? new Date(b.completed_at || 0).getTime() : new Date(b.created_at).getTime(); + return (bTime - aTime); // Newest first within group case 'worker': return (getWorkerName(a)).localeCompare(getWorkerName(b)) * dir; case 'duration':