Initial commit - WhyRating Engine (Google Reviews Scraper)

This commit is contained in:
Alejandro Gutiérrez
2026-02-02 18:19:00 +00:00
parent 0543a08242
commit 2206ddeff2
136 changed files with 51138 additions and 855 deletions

View File

@@ -0,0 +1,509 @@
'use client';
import React, { useState, useMemo } from 'react';
import Link from 'next/link';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
ColumnDef,
flexRender,
SortingState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { ExecutionStatus } from '@/lib/pipeline-types';
import { JobStatus } from './ScraperTest';
interface ReportsViewProps {
executions: ExecutionStatus[];
jobsMap: Map<string, JobStatus>;
onRefresh?: () => void;
}
// Helper to format duration
function formatDuration(ms: number | null | undefined): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
}
// Sort icon component
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
if (!sorted) {
return (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return (
<svg className={`w-4 h-4 ${sorted === 'asc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
// Extract business name from execution using jobs map
function extractBusinessName(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): string {
// First try to get from the linked job
if (execution.job_id) {
const job = jobsMap.get(execution.job_id);
if (job?.business_name) return job.business_name;
}
// Try to get from result_summary
if (execution.result_summary) {
const summary = execution.result_summary as Record<string, unknown>;
if (summary.business_name) return String(summary.business_name);
}
// Try to get from input_summary
if (execution.input_summary) {
const summary = execution.input_summary as Record<string, unknown>;
if (summary.business_name) return String(summary.business_name);
}
// Fallback to business_id
if (execution.business_id) return execution.business_id;
return 'Unknown Business';
}
// Extract review count from execution using jobs map
function extractReviewCount(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): number | null {
// First try to get from the linked job
if (execution.job_id) {
const job = jobsMap.get(execution.job_id);
if (job?.reviews_count) return job.reviews_count;
}
if (execution.result_summary) {
const summary = execution.result_summary as Record<string, unknown>;
if (typeof summary.review_count === 'number') return summary.review_count;
if (typeof summary.reviews_processed === 'number') return summary.reviews_processed;
}
if (execution.input_summary) {
const summary = execution.input_summary as Record<string, unknown>;
if (typeof summary.review_count === 'number') return summary.review_count;
}
return null;
}
export default function ReportsView({ executions, jobsMap, onRefresh }: ReportsViewProps) {
const [sorting, setSorting] = useState<SortingState>([{ id: 'created_at', desc: true }]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
// Filter executions by status
const filteredExecutions = useMemo(() => {
if (statusFilter === 'all') return executions;
return executions.filter(e => e.status === statusFilter);
}, [executions, statusFilter]);
// Status counts for filter badges
const statusCounts = useMemo(() => {
const counts: Record<string, number> = { all: executions.length };
executions.forEach(e => {
counts[e.status] = (counts[e.status] || 0) + 1;
});
return counts;
}, [executions]);
// Calculate summary stats
const stats = useMemo(() => {
const completed = executions.filter(e => e.status === 'completed');
const failed = executions.filter(e => e.status === 'failed');
const running = executions.filter(e => e.status === 'running');
const totalReviews = completed.reduce((sum, e) => {
const count = extractReviewCount(e, jobsMap);
return sum + (count || 0);
}, 0);
const avgDuration = completed.length > 0
? completed.reduce((sum, e) => sum + (e.total_duration_ms || 0), 0) / completed.length
: 0;
const successRate = executions.length > 0
? (completed.length / executions.length) * 100
: 0;
// Reports today
const today = new Date();
today.setHours(0, 0, 0, 0);
const reportsToday = executions.filter(e => new Date(e.created_at || '') >= today).length;
return {
total: executions.length,
completed: completed.length,
failed: failed.length,
running: running.length,
totalReviews,
avgDuration,
successRate,
reportsToday,
};
}, [executions, jobsMap]);
const columns = useMemo<ColumnDef<ExecutionStatus>[]>(
() => [
{
accessorKey: 'business',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Business
<SortIcon sorted={column.getIsSorted()} />
</button>
),
accessorFn: (row) => extractBusinessName(row, jobsMap),
cell: ({ row }) => {
const name = extractBusinessName(row.original, jobsMap);
const reviewCount = extractReviewCount(row.original, jobsMap);
return (
<div className="max-w-xs">
<div className="font-semibold text-gray-900 truncate" title={name}>
{name}
</div>
{reviewCount !== null && (
<div className="text-xs text-gray-500">
{reviewCount.toLocaleString()} reviews analyzed
</div>
)}
</div>
);
},
},
{
accessorKey: 'job_id',
header: 'Job',
cell: ({ row }) => {
const jobId = row.original.job_id;
if (!jobId) return <span className="text-gray-400">-</span>;
return (
<Link
href={`/jobs/${jobId}`}
className="text-blue-600 hover:text-blue-800 text-xs font-mono bg-blue-50 px-2 py-1 rounded"
onClick={(e) => e.stopPropagation()}
>
{jobId.slice(0, 8)}...
</Link>
);
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Status
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => {
const status = row.original.status;
return (
<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 === 'running' ? 'bg-blue-100 text-blue-800' :
status === 'failed' ? 'bg-red-100 text-red-800' :
status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{status === 'running' && (
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
)}
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
},
{
accessorKey: 'stages_completed',
header: 'Stages',
cell: ({ row }) => {
const requested = row.original.stages_requested || [];
const completed = row.original.stages_completed || [];
return (
<div className="flex items-center gap-1">
{requested.map((stage) => (
<span
key={stage}
className={`px-1.5 py-0.5 text-xs rounded ${
completed.includes(stage)
? 'bg-green-100 text-green-700'
: row.original.current_stage === stage
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500'
}`}
title={stage}
>
{stage.slice(0, 3)}
</span>
))}
</div>
);
},
},
{
accessorKey: 'total_duration_ms',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Duration
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => (
<span className="text-sm text-gray-600 font-medium">
{formatDuration(row.original.total_duration_ms)}
</span>
),
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Created
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => {
const dateStr = row.original.created_at;
if (!dateStr) return <span className="text-gray-400">-</span>;
const date = new Date(dateStr);
const isToday = new Date().toDateString() === date.toDateString();
return (
<div>
<div className="font-medium text-gray-900">
{isToday ? 'Today' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-gray-500">
{date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
);
},
sortingFn: (rowA, rowB) => {
const dateA = rowA.original.created_at ? new Date(rowA.original.created_at).getTime() : 0;
const dateB = rowB.original.created_at ? new Date(rowB.original.created_at).getTime() : 0;
return dateA - dateB;
},
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const execution = row.original;
const isCompleted = execution.status === 'completed';
const hasJobId = !!execution.job_id;
return (
<div className="flex items-center gap-2">
{/* View Report - only for completed with job_id */}
{isCompleted && hasJobId && (
<Link
href={`/pipelines/reviewiq/analytics?job_id=${execution.job_id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-semibold rounded-lg transition-colors"
onClick={(e) => e.stopPropagation()}
title="View Report"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
View Report
</Link>
)}
{/* Metrics button */}
<Link
href={`/pipelines/reviewiq/executions/${execution.id}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-medium rounded transition-colors"
onClick={(e) => e.stopPropagation()}
title="View Execution Details"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Details
</Link>
</div>
);
},
},
],
[jobsMap]
);
const table = useReactTable({
data: filteredExecutions,
columns,
state: {
sorting,
columnFilters,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 10,
},
},
});
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Total Reports</div>
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
<div className="text-2xl font-bold text-green-600">{stats.successRate.toFixed(0)}%</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Reviews Analyzed</div>
<div className="text-2xl font-bold text-blue-600">{stats.totalReviews.toLocaleString()}</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Today</div>
<div className="text-2xl font-bold text-purple-600">{stats.reportsToday}</div>
</div>
</div>
{/* Filters */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{['all', 'completed', 'running', 'failed', 'pending'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
statusFilter === status
? status === 'completed' ? 'bg-green-600 text-white' :
status === 'running' ? 'bg-blue-600 text-white' :
status === 'failed' ? 'bg-red-600 text-white' :
'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
{statusCounts[status] !== undefined && (
<span className="ml-1.5 text-xs opacity-80">({statusCounts[status]})</span>
)}
</button>
))}
</div>
{onRefresh && (
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
)}
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-gray-500">
<div className="flex flex-col items-center gap-2">
<svg className="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>No reports found</span>
<span className="text-sm text-gray-400">Run a pipeline to generate reports</span>
</div>
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="hover:bg-gray-50 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {table.getRowModel().rows.length} of {filteredExecutions.length} reports
</div>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
);
}