Add URL-based routing with sidebar navigation

Replace client-side state switching with proper Next.js routes:
- /new - New scrape form
- /jobs - Jobs list with table view
- /jobs/[id] - Individual job details and logs
- /analytics - Analytics overview (completed jobs)
- /analytics/[id] - Analytics for specific job

Add JobsContext for shared state across routes. Update Sidebar
to use next/link with pathname matching. Root page redirects to /new.

Also adds partial job status styling to JobsView.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 10:58:48 +00:00
parent 3eda9bdbfa
commit b1296059a9
10 changed files with 931 additions and 288 deletions

View File

@@ -154,6 +154,20 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
}
});
eventSource.addEventListener('job_partial', (event) => {
try {
const data = JSON.parse(event.data);
setRunningJobUpdates(prev => {
const updated = new Map(prev);
updated.delete(data.job_id);
return updated;
});
onRefresh?.();
} catch (err) {
console.error('Failed to parse job_partial event:', err);
}
});
eventSource.onerror = () => {
console.log('SSE connection error, reconnecting...');
eventSource?.close();
@@ -499,6 +513,7 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
status === 'completed' ? 'bg-green-100 text-green-800' :
status === 'partial' ? 'bg-orange-100 text-orange-800' :
isStuck ? 'bg-red-100 text-red-800' :
status === 'running' ? 'bg-blue-100 text-blue-800' :
status === 'failed' ? 'bg-red-100 text-red-800' :
@@ -507,6 +522,11 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
{status === 'running' && !isStuck && (
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
)}
{status === 'partial' && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
{isStuck && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -571,7 +591,55 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
</button>
),
cell: ({ row }) => {
const time = row.original.scrape_time;
const job = row.original;
const isRunning = job.status === 'running';
const isPartial = job.status === 'partial';
const isStuck = isRunning &&
new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000;
// For actively running jobs (not stuck), show live elapsed time
if (isRunning && !isStuck && job.started_at) {
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
return (
<span className="font-medium text-blue-600 flex items-center gap-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
{formatDuration(elapsed)}
</span>
);
}
// For stuck jobs, show frozen elapsed time in red (no pulse)
if (isStuck && job.started_at) {
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
return (
<span className="font-medium text-red-600">
{formatDuration(elapsed)}
</span>
);
}
// For partial jobs, use scrape_time or calculate from timestamps
if (isPartial) {
const time = job.scrape_time;
if (time !== null) {
return (
<span className="font-medium text-orange-600">
{formatDuration(time)}
</span>
);
}
// Fallback: calculate from started_at to completed_at
if (job.started_at && job.completed_at) {
const elapsed = (new Date(job.completed_at).getTime() - new Date(job.started_at).getTime()) / 1000;
return (
<span className="font-medium text-orange-600">
{formatDuration(elapsed)}
</span>
);
}
}
const time = job.scrape_time;
if (time === null) return <span className="text-gray-400">-</span>;
return (
@@ -592,9 +660,65 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
<SortIcon sorted={column.getIsSorted()} />
</button>
),
accessorFn: (row) => calculateSpeed(row.reviews_count, row.scrape_time),
accessorFn: (row) => {
const isStuck = row.status === 'running' &&
new Date().getTime() - new Date(row.created_at).getTime() > 10 * 60 * 1000;
// For actively running jobs (not stuck), calculate speed from elapsed time
if (row.status === 'running' && !isStuck && row.started_at && row.reviews_count) {
const elapsed = (Date.now() - new Date(row.started_at).getTime()) / 1000;
return elapsed > 0 ? row.reviews_count / elapsed : null;
}
return calculateSpeed(row.reviews_count, row.scrape_time);
},
cell: ({ row }) => {
const speed = calculateSpeed(row.original.reviews_count, row.original.scrape_time);
const job = row.original;
const isRunning = job.status === 'running';
const isPartial = job.status === 'partial';
const isStuck = isRunning &&
new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000;
// For actively running jobs (not stuck), show live speed
if (isRunning && !isStuck && job.started_at && job.reviews_count) {
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
const isGood = speed >= 1;
const isSlow = speed < 0.5;
return (
<span className={`font-medium flex items-center gap-1 ${
isGood ? 'text-green-600' : isSlow ? 'text-orange-500' : 'text-blue-600'
}`}>
<div className="w-2 h-2 bg-current rounded-full animate-pulse" />
{speed.toFixed(1)}/s
</span>
);
}
// For stuck jobs, show frozen speed in red
if (isStuck && job.started_at && job.reviews_count) {
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
return (
<span className="font-medium text-red-600">
{speed.toFixed(1)}/s
</span>
);
}
// For partial jobs, show speed in orange
if (isPartial) {
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
if (speed !== null) {
return (
<span className="font-medium text-orange-600">
{speed.toFixed(1)}/s
</span>
);
}
}
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
if (speed === null) return <span className="text-gray-400">-</span>;
const isGood = speed >= 1;
@@ -644,21 +768,38 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
header: 'Actions',
cell: ({ row }) => {
const job = row.original;
const canView = job.status === 'completed' && job.reviews_count;
const canView = job.reviews_count && job.reviews_count > 0;
const isRunning = job.status === 'running';
const isPartial = job.status === 'partial';
const previousJob = findPreviousJob(job);
return (
<div className="flex items-center gap-2">
{/* View Reviews */}
{/* View Reviews - available for any job with reviews */}
{canView && (
<button
onClick={() => onSelectJob(job, previousJob)}
className="px-2.5 py-1.5 bg-blue-600 text-white text-xs font-semibold rounded-lg hover:bg-blue-700 transition-colors"
className={`px-2.5 py-1.5 text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5 ${
isRunning
? 'bg-purple-600 text-white hover:bg-purple-700'
: isPartial
? 'bg-orange-600 text-white hover:bg-orange-700'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
title={isRunning ? 'Preview analytics (job still running)' : isPartial ? 'View partial results' : 'View analytics'}
>
{isLoadingJob === job.job_id ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
'View'
<>
{isRunning && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
{isRunning ? 'Preview' : isPartial ? 'Partial' : 'View'}
</>
)}
</button>
)}

View File

@@ -1,64 +1,73 @@
'use client';
interface SidebarProps {
activeView: 'newScrape' | 'jobs' | 'reports';
onViewChange: (view: 'newScrape' | 'jobs' | 'reports') => void;
jobCount: number;
}
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useJobs } from '@/contexts/JobsContext';
export default function Sidebar() {
const pathname = usePathname();
const { jobs } = useJobs();
export default function Sidebar({ activeView, onViewChange, jobCount }: SidebarProps) {
const navItems = [
{
id: 'newScrape' as const,
href: '/new',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
label: 'New Scrape',
label: 'New',
matchPaths: ['/new'],
},
{
id: 'jobs' as const,
href: '/jobs',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
label: 'Jobs',
badge: jobCount > 0 ? jobCount : undefined,
matchPaths: ['/jobs'],
badge: jobs.length > 0 ? jobs.length : undefined,
},
{
id: 'reports' as const,
href: '/analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
label: 'Reports',
label: 'Analytics',
matchPaths: ['/analytics'],
},
];
const isActive = (item: typeof navItems[0]) => {
// Check if current path starts with any of the match paths
return item.matchPaths.some(path => pathname.startsWith(path));
};
return (
<div className="w-20 bg-gray-900 flex flex-col items-center py-6 gap-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
<Link
key={item.href}
href={item.href}
className={`relative w-14 h-14 rounded-xl flex flex-col items-center justify-center gap-1 transition-all ${
activeView === item.id
isActive(item)
? 'bg-blue-600 text-white shadow-lg'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
title={item.label}
>
{item.icon}
<span className="text-[10px] font-medium">{item.label.split(' ')[0]}</span>
<span className="text-[10px] font-medium">{item.label}</span>
{item.badge !== undefined && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</button>
</Link>
))}
</div>
);