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

@@ -293,10 +293,10 @@ ${JSON.stringify(execution.result_summary || {}, null, 2)}
{/* Analytics button - only for completed with job_id */}
{isCompleted && execution.job_id && (
<Link
href={`/analytics/${execution.job_id}`}
href={`/pipelines/${pipelineId}/analytics?job_id=${execution.job_id}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700 text-xs font-medium rounded transition-colors"
onClick={(e) => e.stopPropagation()}
title="View Job Analytics"
title="View Pipeline Analytics"
>
<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="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" />

View File

@@ -637,7 +637,7 @@ ${logsFormatted}
<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 ? 'Stuck' : status.charAt(0).toUpperCase() + status.slice(1)}
{isStuck ? 'Stuck' : status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown'}
</span>
</div>
);

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>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import ReviewAnalytics from './ReviewAnalytics';
interface Review {
@@ -63,6 +64,9 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
const [businessRating, setBusinessRating] = useState<number | null>(null);
const [businessImage, setBusinessImage] = useState<string | null>(null);
const [businessCategory, setBusinessCategory] = useState<string | null>(null);
// Session handoff - store session_id from validation for browser reuse
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionExpiresIn, setSessionExpiresIn] = useState<number | null>(null);
// Scraper version selection - v1.1.0 is default (multi-sort enabled)
const AVAILABLE_VERSIONS = [
@@ -309,6 +313,7 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
}, [jobs, onJobsChange]);
// Check for reviews function (called manually when user clicks Validate)
// Uses session handoff - keeps browser alive for reuse during scraping
const checkReviews = async (query: string) => {
// Abort any previous validation request
if (abortControllerRef.current) {
@@ -323,6 +328,8 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
setBusinessRating(null);
setBusinessImage(null);
setBusinessCategory(null);
setSessionId(null);
setSessionExpiresIn(null);
setError('');
// Create new abort controller with 60 second timeout (validation can be slow)
@@ -334,13 +341,15 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
// Force English with hl=en parameter
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}&hl=en`;
const response = await fetch('/api/check-reviews', {
// Use session validation endpoint - keeps browser alive for reuse
const response = await fetch('/api/sessions/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
geolocation: userFingerprint.geolocation,
browser_fingerprint: userFingerprint // Pass full fingerprint
browser_fingerprint: userFingerprint, // Pass full fingerprint
session_ttl: 300 // 5 minute session TTL
}),
signal: controller.signal,
});
@@ -350,13 +359,19 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
const data = await response.json();
if (response.ok && data.success) {
setHasReviews(data.has_reviews);
const businessInfo = data.business_info || {};
setHasReviews(data.total_reviews > 0);
setAvailableReviewCount(data.total_reviews || 0);
setBusinessName(data.name);
setBusinessAddress(data.address);
setBusinessRating(data.rating);
setBusinessImage(data.image_url);
setBusinessCategory(data.category);
setBusinessName(businessInfo.name);
setBusinessAddress(businessInfo.address);
setBusinessRating(businessInfo.rating);
setBusinessCategory(businessInfo.category);
// Store session_id for browser reuse during scraping
if (data.session_id) {
setSessionId(data.session_id);
setSessionExpiresIn(data.expires_in);
console.log(`Session created: ${data.session_id} (expires in ${data.expires_in}s)`);
}
} else {
console.error('Failed to get business info:', data.error);
// Business not found
@@ -465,21 +480,31 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}&hl=en`;
try {
// Build request body - include session_id if available for browser reuse
const requestBody: Record<string, unknown> = {
url,
business_name: businessName,
business_address: businessAddress,
rating_snapshot: businessRating,
total_reviews_snapshot: availableReviewCount,
geolocation: userFingerprint.geolocation,
browser_fingerprint: userFingerprint, // Pass full fingerprint
// Google Reviews scraper (this component is specific to Google Reviews)
job_type: 'google-reviews',
scraper_version: scraperVersion, // Selected scraper version
};
// If we have a session_id from validation, use it for browser reuse
// This saves 4-16 seconds by skipping navigation
if (sessionId) {
requestBody.session_id = sessionId;
console.log(`Using session handoff: ${sessionId}`);
}
const response = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
business_name: businessName,
business_address: businessAddress,
rating_snapshot: businessRating,
total_reviews_snapshot: availableReviewCount,
geolocation: userFingerprint.geolocation,
browser_fingerprint: userFingerprint, // Pass full fingerprint
// Google Reviews scraper (this component is specific to Google Reviews)
job_type: 'google-reviews',
scraper_version: scraperVersion, // Selected scraper version
}),
body: JSON.stringify(requestBody),
});
const data = await response.json();
@@ -972,6 +997,7 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
</div>
{Array.from(jobs.values())
.filter(job => job && job.status)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map(job => (
<div
@@ -1278,15 +1304,14 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
</>
)}
{/* Confirmation Modal */}
{showConfirmModal && (
{/* Confirmation Modal - rendered via portal to modal-root for proper centering */}
{showConfirmModal && typeof document !== 'undefined' && document.getElementById('modal-root') && createPortal(
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => setShowConfirmModal(false)}
>
<div
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl border-2 border-green-500 animate-fade-in"
style={{ width: '400px', maxWidth: 'calc(100vw - 32px)' }}
className="bg-white rounded-2xl shadow-2xl border-2 border-green-500 animate-fade-in w-full max-w-[400px]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
@@ -1370,7 +1395,8 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
</button>
</div>
</div>
</div>
</div>,
document.getElementById('modal-root')!
)}
</div>
);

View File

@@ -40,6 +40,16 @@ export default function Sidebar() {
matchPaths: ['/jobs'],
badge: jobs.length > 0 ? jobs.length : undefined,
},
{
href: '/reports',
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 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>
),
label: 'Reports',
matchPaths: ['/reports'],
},
{
href: '/analytics',
icon: (
@@ -70,6 +80,26 @@ export default function Sidebar() {
label: 'Scrapers',
matchPaths: ['/dashboard/scrapers'],
},
{
href: '/taxonomy/urt/v5-1',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
</svg>
),
label: 'Taxonomy',
matchPaths: ['/taxonomy'],
},
{
href: '/categories',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
label: 'GBP Cats',
matchPaths: ['/categories'],
},
];
const isActive = (item: typeof navItems[0]) => {

View File

@@ -10,6 +10,7 @@ interface DashboardSectionProps {
section: DashboardSectionType;
pipelineId: string;
businessId?: string;
jobId?: string;
timeRange?: string;
}
@@ -20,6 +21,7 @@ export function DashboardSection({
section,
pipelineId,
businessId,
jobId,
timeRange = '30d',
}: DashboardSectionProps) {
const [collapsed, setCollapsed] = useState(section.collapsed ?? false);
@@ -37,6 +39,7 @@ export function DashboardSection({
try {
const data = await getWidgetData(pipelineId, widgetId, {
business_id: businessId,
job_id: jobId,
time_range: timeRange,
page: page || tablePagination[widgetId] || 1,
});
@@ -50,7 +53,7 @@ export function DashboardSection({
setWidgetLoading((prev) => ({ ...prev, [widgetId]: false }));
}
},
[pipelineId, businessId, timeRange, tablePagination]
[pipelineId, businessId, jobId, timeRange, tablePagination]
);
// Fetch all widget data on mount and when params change
@@ -60,7 +63,7 @@ export function DashboardSection({
fetchWidgetData(widget.id);
});
}
}, [section.widgets, collapsed, pipelineId, businessId, timeRange]);
}, [section.widgets, collapsed, pipelineId, businessId, jobId, timeRange]);
// Handle page change for tables
const handlePageChange = (widgetId: string, page: number) => {
@@ -109,7 +112,7 @@ export function DashboardSection({
<ChevronDown className="w-5 h-5 text-gray-500 mr-2" />
)}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600">
<h2 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600">
{section.title}
</h2>
{section.description && (

View File

@@ -9,6 +9,7 @@ interface DynamicDashboardProps {
pipelineId: string;
config: DashboardConfig;
businessId?: string;
jobId?: string;
}
// Time range options
@@ -31,6 +32,7 @@ export function DynamicDashboard({
pipelineId,
config,
businessId: initialBusinessId,
jobId,
}: DynamicDashboardProps) {
const [timeRange, setTimeRange] = useState(config.default_time_range || '30d');
const [businessId, setBusinessId] = useState(initialBusinessId);
@@ -46,11 +48,11 @@ export function DynamicDashboard({
{/* Dashboard Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<h1 className="text-2xl font-bold text-gray-900">
{config.title}
</h1>
{config.description && (
<p className="text-gray-500 mt-1">{config.description}</p>
<p className="text-gray-600 mt-1">{config.description}</p>
)}
</div>
@@ -58,7 +60,7 @@ export function DynamicDashboard({
<div className="flex items-center space-x-3">
{/* Business Filter (placeholder) */}
{businessId && (
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded-md">
<div className="flex items-center text-sm text-gray-700 bg-gray-100 px-3 py-2 rounded-md">
<Building2 className="w-4 h-4 mr-2" />
<span className="truncate max-w-[150px]">{businessId}</span>
</div>
@@ -69,7 +71,7 @@ export function DynamicDashboard({
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
className="appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
className="appearance-none bg-white border border-gray-300 rounded-md pl-9 pr-8 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{TIME_RANGES.map((range) => (
<option key={range.value} value={range.value}>
@@ -83,7 +85,7 @@ export function DynamicDashboard({
{/* Refresh Button */}
<button
onClick={handleRefresh}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md"
title="Refresh all widgets"
>
<RefreshCw className="w-5 h-5" />
@@ -98,6 +100,7 @@ export function DynamicDashboard({
section={section}
pipelineId={pipelineId}
businessId={businessId}
jobId={jobId}
timeRange={timeRange}
/>
))}

View File

@@ -42,7 +42,7 @@ export function BarChartWidget({
error,
onRefresh,
}: BarChartWidgetProps) {
const chartConfig = config.config as ChartWidgetConfig;
const chartConfig = config.config as unknown as ChartWidgetConfig;
const chartData = data?.data || [];
return (
@@ -58,25 +58,25 @@ export function BarChartWidget({
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
{chartConfig.show_grid !== false && (
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<CartesianGrid strokeDasharray="3 3" stroke="#d1d5db" />
)}
<XAxis
dataKey={chartConfig.x_axis?.key || 'x'}
tick={{ fontSize: 12 }}
tick={{ fontSize: 12, fill: '#374151' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
axisLine={{ stroke: '#d1d5db' }}
/>
<YAxis
tick={{ fontSize: 12 }}
tick={{ fontSize: 12, fill: '#374151' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
axisLine={{ stroke: '#d1d5db' }}
label={
chartConfig.y_axis?.label
? {
value: chartConfig.y_axis.label,
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
style: { fontSize: 12, fill: '#374151' },
}
: undefined
}

View File

@@ -26,7 +26,7 @@ export function DataTableWidget({
onPageChange,
currentPage = 1,
}: DataTableWidgetProps) {
const tableConfig = config.config as TableWidgetConfig;
const tableConfig = config.config as unknown as TableWidgetConfig;
const rows = data?.data || [];
const total = data?.total || 0;
const pageSize = tableConfig.page_size || 10;
@@ -42,13 +42,13 @@ export function DataTableWidget({
<div className="flex flex-col h-full">
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800 sticky top-0">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0">
<tr>
{tableConfig.columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${
className={`px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider ${
col.align === 'right'
? 'text-right'
: col.align === 'center'
@@ -62,16 +62,16 @@ export function DataTableWidget({
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white divide-y divide-gray-200">
{rows.map((row, rowIndex) => (
<tr
key={row[tableConfig.row_key] as string || rowIndex}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
className="hover:bg-gray-50"
>
{tableConfig.columns.map((col) => (
<td
key={col.key}
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap ${
className={`px-4 py-3 text-sm text-gray-900 whitespace-nowrap ${
col.align === 'right'
? 'text-right'
: col.align === 'center'
@@ -90,8 +90,8 @@ export function DataTableWidget({
{/* Pagination */}
{tableConfig.show_pagination !== false && totalPages > 1 && onPageChange && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500">
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<div className="text-sm text-gray-600">
Showing {(currentPage - 1) * pageSize + 1} to{' '}
{Math.min(currentPage * pageSize, total)} of {total}
</div>
@@ -99,17 +99,17 @@ export function DataTableWidget({
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1 rounded text-gray-600 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-sm text-gray-700">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1 rounded text-gray-600 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>

View File

@@ -21,7 +21,7 @@ export function HeatmapWidget({
error,
onRefresh,
}: HeatmapWidgetProps) {
const heatmapConfig = config.config as HeatmapConfig;
const heatmapConfig = config.config as unknown as HeatmapConfig;
const rawData = data?.data || [];
// Extract unique x and y values
@@ -77,7 +77,7 @@ export function HeatmapWidget({
<tbody>
{yValues.map((y) => (
<tr key={y}>
<td className="px-2 py-2 text-xs font-medium text-gray-700 dark:text-gray-300">
<td className="px-2 py-2 text-xs font-medium text-gray-700">
{y}
</td>
{xValues.map((x) => {

View File

@@ -42,7 +42,7 @@ export function LineChartWidget({
error,
onRefresh,
}: LineChartWidgetProps) {
const chartConfig = config.config as ChartWidgetConfig;
const chartConfig = config.config as unknown as ChartWidgetConfig;
const chartData = data?.data || [];
return (
@@ -58,25 +58,25 @@ export function LineChartWidget({
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
{chartConfig.show_grid !== false && (
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<CartesianGrid strokeDasharray="3 3" stroke="#d1d5db" />
)}
<XAxis
dataKey={chartConfig.x_axis?.key || 'x'}
tick={{ fontSize: 12 }}
tick={{ fontSize: 12, fill: '#374151' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
axisLine={{ stroke: '#d1d5db' }}
/>
<YAxis
tick={{ fontSize: 12 }}
tick={{ fontSize: 12, fill: '#374151' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
axisLine={{ stroke: '#d1d5db' }}
label={
chartConfig.y_axis?.label
? {
value: chartConfig.y_axis.label,
angle: -90,
position: 'insideLeft',
style: { fontSize: 12 },
style: { fontSize: 12, fill: '#374151' },
}
: undefined
}

View File

@@ -41,7 +41,7 @@ export function PieChartWidget({
error,
onRefresh,
}: PieChartWidgetProps) {
const chartConfig = config.config as PieChartConfig;
const chartConfig = config.config as unknown as PieChartConfig;
const chartData = data?.data || [];
const colors = chartConfig.colors || DEFAULT_COLORS;
const innerRadius = chartConfig.inner_radius || 0; // 0 = pie, > 0 = donut
@@ -71,7 +71,7 @@ export function PieChartWidget({
nameKey="name"
label={
chartConfig.show_labels !== false
? ({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`
? ({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`
: undefined
}
labelLine={chartConfig.show_labels !== false}
@@ -89,7 +89,7 @@ export function PieChartWidget({
border: '1px solid #e5e7eb',
borderRadius: '0.375rem',
}}
formatter={(value: number) => [value.toLocaleString(), 'Count']}
formatter={(value) => [(value ?? 0).toLocaleString(), 'Count']}
/>
{chartConfig.show_legend !== false && (
<Legend

View File

@@ -29,14 +29,14 @@ const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
activity: Activity,
};
// Color mapping
// Color mapping (light mode optimized for bg-gray-50 background)
const COLORS: Record<string, string> = {
blue: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900/30',
green: 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30',
red: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30',
yellow: 'text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30',
purple: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-900/30',
gray: 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-700',
blue: 'text-blue-700 bg-blue-100',
green: 'text-green-700 bg-green-100',
red: 'text-red-700 bg-red-100',
yellow: 'text-yellow-700 bg-yellow-100',
purple: 'text-purple-700 bg-purple-100',
gray: 'text-gray-700 bg-gray-100',
};
/**
@@ -70,7 +70,7 @@ function formatValue(value: number | string, format?: string): string {
* Stat card widget for displaying KPIs.
*/
export function StatCard({ config, data, loading, error, onRefresh }: StatCardProps) {
const widgetConfig = config.config as StatCardConfig;
const widgetConfig = config.config as unknown as StatCardConfig;
const Icon = widgetConfig.icon ? ICONS[widgetConfig.icon] : Activity;
const colorClass = widgetConfig.color ? COLORS[widgetConfig.color] : COLORS.gray;
@@ -82,7 +82,7 @@ export function StatCard({ config, data, loading, error, onRefresh }: StatCardPr
<WidgetWrapper config={config} loading={loading} error={error} onRefresh={onRefresh}>
<div className="flex items-center justify-between h-full">
<div className="flex-1">
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
<p className="text-3xl font-bold text-gray-900">
{formatValue(value, widgetConfig.format)}
</p>
{trend !== undefined && (

View File

@@ -23,17 +23,17 @@ export function WidgetWrapper({
children,
}: WidgetWrapperProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm h-full flex flex-col">
<div className="bg-white rounded-lg border-2 border-gray-200 shadow-sm h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-gray-900 dark:text-gray-100 text-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm">
{config.title}
</h3>
{onRefresh && (
<button
onClick={onRefresh}
disabled={loading}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
@@ -47,14 +47,14 @@ export function WidgetWrapper({
<div className="flex items-center justify-center h-full">
<div className="text-center">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<p className="text-sm text-red-600">{error}</p>
</div>
</div>
) : loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-pulse flex flex-col items-center">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-24 bg-gray-200 rounded mb-2" />
<div className="h-3 w-16 bg-gray-200 rounded" />
</div>
</div>
) : (

View File

@@ -0,0 +1,511 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { TrendingDown, AlertCircle, Loader2, ChevronDown } from 'lucide-react';
import {
DOMAIN_FRIENDLY,
DOMAIN_COLORS,
TimeRange,
Granularity,
URTDomain,
} from './types';
// ==================== Types ====================
interface TrendDataPoint {
date: string;
count: number;
positive: number;
negative: number;
review_count: number;
sentiment_score: number; // -100 to +100
// Rating impact - THE BUSINESS VALUE
avg_rating_negative: number | null; // Avg stars when complaints mention this category
avg_rating_positive: number | null; // Avg stars when praise mentions this category
}
interface TrendItem {
id: string;
label: string;
color: string;
data: TrendDataPoint[];
}
interface ExplorerViewProps {
jobId?: string;
businessId?: string;
}
// ==================== Constants ====================
const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string }[] = [
{ value: '7d', label: '7D', description: 'Last 7 days' },
{ value: '14d', label: '2W', description: 'Last 2 weeks' },
{ value: '30d', label: '1M', description: 'Last month' },
{ value: '90d', label: '3M', description: 'Last 3 months' },
{ value: '1y', label: '1Y', description: 'Last year' },
{ value: 'all', label: 'All', description: 'All time' },
];
const DOMAIN_OPTIONS: { value: URTDomain; label: string; emoji: string; color: string }[] = [
{ value: 'P', label: DOMAIN_FRIENDLY['P'].label, emoji: DOMAIN_FRIENDLY['P'].emoji, color: DOMAIN_COLORS['P'] },
{ value: 'V', label: DOMAIN_FRIENDLY['V'].label, emoji: DOMAIN_FRIENDLY['V'].emoji, color: DOMAIN_COLORS['V'] },
{ value: 'J', label: DOMAIN_FRIENDLY['J'].label, emoji: DOMAIN_FRIENDLY['J'].emoji, color: DOMAIN_COLORS['J'] },
{ value: 'O', label: DOMAIN_FRIENDLY['O'].label, emoji: DOMAIN_FRIENDLY['O'].emoji, color: DOMAIN_COLORS['O'] },
{ value: 'A', label: DOMAIN_FRIENDLY['A'].label, emoji: DOMAIN_FRIENDLY['A'].emoji, color: DOMAIN_COLORS['A'] },
{ value: 'E', label: DOMAIN_FRIENDLY['E'].label, emoji: DOMAIN_FRIENDLY['E'].emoji, color: DOMAIN_COLORS['E'] },
{ value: 'R', label: DOMAIN_FRIENDLY['R'].label, emoji: DOMAIN_FRIENDLY['R'].emoji, color: DOMAIN_COLORS['R'] },
];
// Metric options for dropdown
type MetricType = 'damage' | 'sentiment';
const METRIC_OPTIONS: { value: MetricType; label: string; description: string }[] = [
{ value: 'damage', label: '📉 Reputation Damage', description: 'Cumulative star loss from complaints' },
{ value: 'sentiment', label: '📊 Net Mentions', description: 'Positive minus negative (cumulative)' },
];
// Map time range to granularity
const getGranularity = (timeRange: TimeRange): Granularity => {
switch (timeRange) {
case '7d':
case '14d':
return 'day';
case '30d':
return 'week';
case '90d':
return 'week';
case '1y':
return 'month';
case 'all':
return 'month';
default:
return 'week';
}
};
// ==================== Component ====================
const MAX_CATEGORIES = 4;
export function ExplorerView({ jobId, businessId }: ExplorerViewProps) {
// State
const [selectedCategories, setSelectedCategories] = useState<URTDomain[]>(['P', 'V']);
const [selectedMetric, setSelectedMetric] = useState<MetricType>('damage');
const [timeRange, setTimeRange] = useState<TimeRange>('1y');
const [trendData, setTrendData] = useState<TrendItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isMetricOpen, setIsMetricOpen] = useState(false);
// Fetch trend data when selection or time range changes
useEffect(() => {
if (selectedCategories.length === 0) {
setTrendData([]);
return;
}
const fetchTrendData = async () => {
setIsLoading(true);
setError(null);
try {
const granularity = getGranularity(timeRange);
const itemsParam = selectedCategories.join(',');
const url = `/api/pipelines/reviewiq/trends?job_id=${jobId}&items=${itemsParam}&time_range=${timeRange}&granularity=${granularity}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch trend data: ${response.statusText}`);
}
const data: TrendItem[] = await response.json();
setTrendData(data);
} catch (err) {
console.error('Error fetching trend data:', err);
setError(err instanceof Error ? err.message : 'Failed to load trend data');
setTrendData([]);
} finally {
setIsLoading(false);
}
};
fetchTrendData();
}, [jobId, selectedCategories, timeRange]);
// Transform data for Recharts - calculate cumulative damage AND normalized sentiment
const chartData = useMemo(() => {
if (trendData.length === 0) return [];
// Get all unique dates
const dateSet = new Set<string>();
trendData.forEach((item) => {
item.data.forEach((d) => dateSet.add(d.date));
});
const dates = Array.from(dateSet).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime()
);
// Track cumulative values per category
const cumulatives: Record<string, {
damage: number;
totalPositive: number;
totalNegative: number;
totalCount: number;
}> = {};
trendData.forEach((item) => {
cumulatives[item.id] = { damage: 0, totalPositive: 0, totalNegative: 0, totalCount: 0 };
});
// Build chart data with cumulative values for each category
return dates.map((date) => {
const point: Record<string, string | number | null> = { date };
trendData.forEach((item) => {
const dataPoint = item.data.find((d) => d.date === date);
if (dataPoint) {
// Damage: complaints * (5 - avg_rating)
const avgRating = dataPoint.avg_rating_negative ?? 3;
const periodDamage = dataPoint.negative * (5 - avgRating);
cumulatives[item.id].damage += periodDamage;
// Track totals for normalized sentiment
cumulatives[item.id].totalPositive += dataPoint.positive;
cumulatives[item.id].totalNegative += dataPoint.negative;
cumulatives[item.id].totalCount += dataPoint.count;
}
// Damage as negative (going down like losses)
point[`${item.id}_damage`] = -Math.round(cumulatives[item.id].damage * 10) / 10;
// Net Mentions: cumulative (positive - negative) - preserves volume!
const { totalPositive, totalNegative } = cumulatives[item.id];
point[`${item.id}_sentiment`] = totalPositive - totalNegative;
// Store period data for tooltips
const dp = item.data.find((d) => d.date === date);
point[`${item.id}_periodDamage`] = dp ? Math.round((dp.negative * (5 - (dp.avg_rating_negative ?? 3))) * 10) / 10 : 0;
point[`${item.id}_complaints`] = dp?.negative ?? 0;
point[`${item.id}_avgRating`] = dp?.avg_rating_negative ?? null;
point[`${item.id}_periodSentiment`] = dp?.sentiment_score ?? 0;
});
return point;
});
}, [trendData]);
// Toggle category selection
const toggleCategory = (category: URTDomain) => {
setSelectedCategories((prev) => {
if (prev.includes(category)) {
return prev.filter((c) => c !== category);
}
if (prev.length >= MAX_CATEGORIES) {
return [...prev.slice(1), category];
}
return [...prev, category];
});
};
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const granularity = getGranularity(timeRange);
switch (granularity) {
case 'day':
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
case 'week':
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
case 'month':
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
case 'year':
return date.toLocaleDateString('en-US', { year: 'numeric' });
default:
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
};
const currentMetric = METRIC_OPTIONS.find(o => o.value === selectedMetric);
return (
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-200 hover:border-red-300 transition-all">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
<div className="flex items-start gap-3">
<div className="p-2 bg-red-100 rounded-lg">
<TrendingDown className="w-6 h-6 text-red-600" />
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">Reputation Tracker</h3>
<p className="text-sm text-gray-500 mt-0.5">
Compare cumulative damage across categories
</p>
</div>
</div>
{/* Time Range Selector */}
<div className="flex items-center bg-gray-100 rounded-lg p-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`px-2.5 py-1.5 text-xs font-semibold rounded-md transition-all ${
timeRange === opt.value
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-200'
}`}
title={opt.description}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Category Chips + Metric Dropdown */}
<div className="mb-6 space-y-3">
{/* Category Toggle Chips */}
<div className="flex flex-wrap gap-2">
{DOMAIN_OPTIONS.map((opt) => {
const isSelected = selectedCategories.includes(opt.value);
return (
<button
key={opt.value}
onClick={() => toggleCategory(opt.value)}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
transition-all border-2
${isSelected
? 'shadow-md'
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50'
}
`}
style={isSelected ? {
backgroundColor: `${opt.color}15`,
borderColor: opt.color,
color: opt.color,
} : undefined}
>
<span className="text-base">{opt.emoji}</span>
<span>{opt.label}</span>
</button>
);
})}
</div>
{/* Metric Dropdown */}
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">Show:</span>
<div className="relative">
<button
onClick={() => setIsMetricOpen(!isMetricOpen)}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg hover:border-gray-300 transition-all text-sm"
>
<span className="font-medium text-gray-700">{currentMetric?.label}</span>
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isMetricOpen ? 'rotate-180' : ''}`} />
</button>
{isMetricOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsMetricOpen(false)} />
<div className="absolute top-full left-0 mt-1 z-20 bg-white rounded-xl shadow-xl border border-gray-200 py-1 min-w-[250px]">
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => { setSelectedMetric(opt.value); setIsMetricOpen(false); }}
className={`w-full flex flex-col items-start px-4 py-2.5 hover:bg-gray-50 transition-colors ${
selectedMetric === opt.value ? 'bg-red-50' : ''
}`}
>
<span className="font-medium text-gray-700">{opt.label}</span>
<span className="text-xs text-gray-500">{opt.description}</span>
</button>
))}
</div>
</>
)}
</div>
<span className="text-xs text-gray-400 ml-auto">
Select up to {MAX_CATEGORIES} categories to compare
</span>
</div>
</div>
{/* Chart Area */}
<div className="relative">
{/* Loading State */}
{isLoading && (
<div className="absolute inset-0 bg-white/80 z-10 flex items-center justify-center rounded-lg">
<div className="flex items-center gap-3 text-red-600">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="font-medium">Loading data...</span>
</div>
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div className="flex flex-col items-center justify-center h-80 text-red-500">
<AlertCircle className="w-12 h-12 mb-3" />
<p className="font-medium mb-1">Failed to load data</p>
<p className="text-sm text-gray-500">{error}</p>
</div>
)}
{/* Empty State - No categories selected */}
{!isLoading && !error && selectedCategories.length === 0 && (
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
<TrendingDown className="w-12 h-12 mb-3" />
<p className="font-medium mb-1">No categories selected</p>
<p className="text-sm">Click categories above to compare their reputation impact</p>
</div>
)}
{/* No Data State */}
{!isLoading && !error && selectedCategories.length > 0 && chartData.length === 0 && (
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
<TrendingDown className="w-12 h-12 mb-3" />
<p className="font-medium mb-1">No data available</p>
<p className="text-sm">Try selecting a different time range</p>
</div>
)}
{/* Chart */}
{!isLoading && !error && chartData.length > 0 && (
<ResponsiveContainer width="100%" height={350}>
<AreaChart
data={chartData}
margin={{ top: 10, right: 30, left: 0, bottom: 10 }}
>
<defs>
{/* Generate gradients for each selected category */}
{trendData.map((item) => (
<linearGradient key={`gradient-${item.id}`} id={`gradient-${item.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={item.color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={item.color} stopOpacity={0.05}/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
tickFormatter={formatDate}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(value) => value.toFixed(0)}
/>
{/* Center reference line at 0 */}
<ReferenceLine y={0} stroke="#9ca3af" strokeDasharray="5 5" strokeWidth={1.5} />
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: 'none',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
padding: '16px',
}}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="min-w-[240px]">
<p className="font-bold text-gray-900 mb-3 pb-2 border-b border-gray-100">
{formatDate(String(label))}
</p>
<div className="space-y-3">
{trendData.map((item) => {
const friendly = DOMAIN_FRIENDLY[item.id];
const value = data[`${item.id}_${selectedMetric}`];
const periodDamage = data[`${item.id}_periodDamage`];
const complaints = data[`${item.id}_complaints`];
return (
<div
key={item.id}
className="rounded-lg px-3 py-2 -mx-1"
style={{ backgroundColor: `${item.color}10` }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span>{friendly?.emoji}</span>
<span className="text-sm font-semibold" style={{ color: item.color }}>
{friendly?.label}
</span>
</div>
<span className="font-bold" style={{ color: item.color }}>
{typeof value === 'number' ? value.toFixed(0) : '-'}
</span>
</div>
<div className="text-xs text-gray-500 flex gap-3">
<span>Period: -{periodDamage} pts</span>
<span>Complaints: {complaints}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
return null;
}}
/>
{/* Render area for each selected category */}
{trendData.map((item) => (
<Area
key={item.id}
type="monotone"
dataKey={`${item.id}_${selectedMetric}`}
name={item.label}
stroke={item.color}
strokeWidth={2.5}
fill={`url(#gradient-${item.id})`}
fillOpacity={0.6}
dot={false}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff', fill: item.color }}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-500 text-center">
{selectedMetric === 'damage'
? 'Each complaint costs points based on how low the rating is. Steeper drops = worse periods.'
: 'Cumulative positive minus negative mentions. Above 0 = net positive. Categories with more volume show bigger swings.'
}
</p>
</div>
</div>
);
}

View File

@@ -15,16 +15,16 @@ import {
ReferenceLine,
} from 'recharts';
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types';
import type { TimelinePoint, TimeRange, Granularity } from '../types';
import { DOMAIN_LABELS } from '../types';
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
interface TimelineChartProps {
data: TimelinePoint[];
// AI-generated insight (optional - shows when available)
// AI-generated insight headline (optional - shows when available)
insight?: string | null;
// Timeline annotations from AI (optional - marks key events)
annotations?: TimelineAnnotation[] | null;
// Timeline granularity from API (day, week, month, year)
granularity?: Granularity;
}
type ViewMode = 'sentiment' | 'volume' | 'rating';
@@ -49,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
* User-friendly design with view toggles and interactive brush.
* Responds to domain/sentiment filters.
*/
export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
export function TimelineChart({ data, insight, granularity = 'week' }: TimelineChartProps) {
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
const [localBrushRange, setLocalBrushRange] = useState<{
@@ -137,10 +137,21 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
const hasSentimentFilter = filters.sentiment.length > 0;
const hasAnyFilter = hasBrushFilter || hasDomainFilter || hasSentimentFilter;
// Format date for display
// Format date for display based on granularity
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
switch (granularity) {
case 'day':
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
case 'week':
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
case 'month':
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
case 'year':
return date.toLocaleDateString('en-US', { year: 'numeric' });
default:
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
};
return (
@@ -281,31 +292,6 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
</div>
)}
{/* Key Events (when annotations available) */}
{annotations && annotations.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{annotations.slice(0, 3).map((annotation, idx) => (
<div
key={idx}
className={`px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1 ${
annotation.type === 'positive' ? 'bg-green-100 text-green-700' :
annotation.type === 'negative' ? 'bg-red-100 text-red-700' :
annotation.type === 'event' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
}`}
title={annotation.description}
>
<span>{
annotation.type === 'positive' ? '📈' :
annotation.type === 'negative' ? '📉' :
annotation.type === 'event' ? '📍' : '•'
}</span>
<span>{annotation.label}</span>
</div>
))}
</div>
)}
{sortedData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-80 text-gray-500">
<Calendar className="w-12 h-12 text-gray-300 mb-2" />

View File

@@ -16,7 +16,7 @@ import {
Award,
} from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation';
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
interface ExecutiveSummaryProps {
@@ -25,8 +25,6 @@ interface ExecutiveSummaryProps {
domainScores?: DomainScore[];
onDriverClick?: (subcode: string) => void;
onDomainClick?: (domain: URTDomain) => void;
// AI-generated narrative (optional - enhances when available)
synthesis?: Synthesis | null;
}
// User-friendly domain config
@@ -204,13 +202,12 @@ export function ExecutiveSummary({
domainScores,
onDriverClick,
onDomainClick,
synthesis,
}: ExecutiveSummaryProps) {
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
const [showFullSummary, setShowFullSummary] = useState(false);
// Use AI narrative if available, otherwise fall back to generated summary
const narrativeText = synthesis?.executive_narrative || executive_summary;
// Use the generated summary from insights
const narrativeText = executive_summary;
const topStrength = strengths[0];
const topWeakness = weaknesses[0];
@@ -294,20 +291,13 @@ export function ExecutiveSummary({
</div>
</div>
{/* AI Summary */}
{/* Summary */}
{narrativeText && (
<div className="px-6 pb-4">
<div className={`p-4 rounded-xl border ${
synthesis?.executive_narrative
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
: 'bg-white/70 border-blue-100'
}`}>
<div className="p-4 rounded-xl border bg-white/70 border-blue-100">
<div className="flex items-start gap-2">
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
<span className="text-lg">💡</span>
<div className="flex-1">
{synthesis?.executive_narrative && (
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
)}
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
{narrativeText}
</p>

View File

@@ -0,0 +1,127 @@
'use client';
import { taxonomy } from '@/lib/taxonomy/data';
const LAYER_COLORS = {
conditions: {
bg: 'bg-yellow-500/10',
border: 'border-yellow-500/30',
text: 'text-yellow-400',
badge: 'bg-yellow-500/20 text-yellow-300',
},
management: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/30',
text: 'text-blue-400',
badge: 'bg-blue-500/20 text-blue-300',
},
systemic: {
bg: 'bg-purple-500/10',
border: 'border-purple-500/30',
text: 'text-purple-400',
badge: 'bg-purple-500/20 text-purple-300',
},
};
const LAYER_TITLES = {
conditions: 'Conditions',
management: 'Management',
systemic: 'Systemic',
};
const LAYER_DESCRIPTIONS = {
conditions: 'What allowed the experience to happen?',
management: 'What decisions allowed enabling conditions?',
systemic: 'Why does the organization create these conditions?',
};
export default function CausalCodesSection() {
const layers = ['conditions', 'management', 'systemic'] as const;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Causal Codes</h2>
<p className="text-gray-400 mt-1">
Three-layer root cause analysis framework with 16 codes
</p>
</div>
{/* Three layers visualization */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{layers.map((layerKey) => {
const layer = taxonomy.causal_codes[layerKey];
const colors = LAYER_COLORS[layerKey];
const codeCount = Object.keys(layer.codes).length;
return (
<div
key={layerKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Layer Header */}
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold ${colors.text}`}>
{LAYER_TITLES[layerKey]}
</h3>
<span className={`text-xs px-2 py-0.5 rounded ${colors.badge}`}>
{layer.prefix}* ({codeCount})
</span>
</div>
<p className="text-sm text-gray-400 mb-4">
{LAYER_DESCRIPTIONS[layerKey]}
</p>
{/* Codes list */}
<div className="space-y-2">
{Object.entries(layer.codes).map(([codeKey, code]) => (
<div
key={codeKey}
className="p-2 bg-gray-800/50 rounded border border-gray-700/50"
>
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${colors.text}`}>
{codeKey}
</span>
<span className="text-sm text-gray-300">{code.name}</span>
</div>
<p className="text-xs text-gray-500 mt-1">{code.definition}</p>
</div>
))}
</div>
</div>
);
})}
</div>
{/* Flow Indicator */}
<div className="flex items-center justify-center gap-4 py-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded">
Conditions
</span>
<span className="text-gray-600"></span>
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded">
Management
</span>
<span className="text-gray-600"></span>
<span className="px-3 py-1 bg-purple-500/20 text-purple-400 rounded">
Systemic
</span>
</div>
</div>
{/* Usage note */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
<p className="text-sm text-gray-400">
Causal codes are used for root cause analysis in URT-Full profile. Start with
Conditions (immediate factors), trace to Management (decisions that enabled conditions),
and finally to Systemic (organizational factors that created the management decisions).
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { taxonomy } from '@/lib/taxonomy/data';
const DIMENSION_COLORS: Record<string, { bg: string; border: string; text: string }> = {
valence: { bg: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-400' },
intensity: { bg: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-400' },
specificity: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-400' },
actionability: { bg: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-400' },
temporal: { bg: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-400' },
evidence: { bg: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-400' },
comparative: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
};
export default function MetadataSection() {
const dimensions = Object.entries(taxonomy.metadata_dimensions);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Metadata Dimensions</h2>
<p className="text-gray-400 mt-1">
7 dimensions with 24 values for enriching classifications
</p>
</div>
{/* Dimension Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{dimensions.map(([dimKey, dimension]) => {
const colors = DIMENSION_COLORS[dimKey] || {
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
text: 'text-gray-400',
};
const valueCount = Object.keys(dimension.values).length;
return (
<div
key={dimKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Dimension Header */}
<div className="flex items-center justify-between mb-2">
<h3 className={`font-semibold ${colors.text}`}>{dimension.name}</h3>
<span className="font-mono text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{dimension.code}
</span>
</div>
<p className="text-sm text-gray-400 mb-4">{dimension.description}</p>
{/* Values */}
<div className="space-y-2">
{Object.entries(dimension.values).map(([valueKey, value]) => (
<div
key={valueKey}
className="flex items-start gap-2 text-sm"
>
<span className={`font-mono ${colors.text} flex-shrink-0`}>
{valueKey}
</span>
<div className="flex-1">
<span className="text-gray-300">{value.label}</span>
{value.markers && value.markers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{value.markers.slice(0, 3).map((marker, idx) => (
<span
key={idx}
className="text-xs text-gray-500 bg-gray-800/50 px-1.5 py-0.5 rounded"
>
&quot;{marker}&quot;
</span>
))}
</div>
)}
{value.example && (
<p className="text-xs text-gray-500 mt-1 italic">
e.g., {value.example}
</p>
)}
</div>
</div>
))}
</div>
{/* Default indicator */}
{dimension.default && (
<div className="mt-3 pt-2 border-t border-gray-700/50">
<span className="text-xs text-gray-500">
Default: <span className="font-mono">{dimension.default}</span>
</span>
</div>
)}
</div>
);
})}
</div>
{/* Usage note */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
<h4 className="font-semibold text-gray-200 mb-2">Usage</h4>
<p className="text-sm text-gray-400">
Metadata dimensions enrich each classification span. Required dimensions vary by profile:
URT-Lite requires only Valence, while URT-Full requires all 7 dimensions.
Dimensions like Comparative (CR) and Evidence (E) have defaults when not applicable.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import { Check, X, Minus } from 'lucide-react';
import { taxonomy } from '@/lib/taxonomy/data';
const PROFILE_COLORS: Record<string, { bg: string; border: string; text: string; badge: string }> = {
lite: {
bg: 'bg-green-500/10',
border: 'border-green-500/30',
text: 'text-green-400',
badge: 'bg-green-500',
},
core: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/30',
text: 'text-blue-400',
badge: 'bg-blue-500',
},
standard: {
bg: 'bg-purple-500/10',
border: 'border-purple-500/30',
text: 'text-purple-400',
badge: 'bg-purple-500',
},
full: {
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
text: 'text-amber-400',
badge: 'bg-amber-500',
},
};
const COMPLEXITY_COLORS: Record<string, string> = {
Minimal: 'text-green-400',
Low: 'text-blue-400',
Medium: 'text-purple-400',
High: 'text-amber-400',
};
const ALL_FIELDS = [
'primary_code',
'secondary_codes',
'valence',
'intensity',
'specificity',
'actionability',
'temporal',
'evidence',
'comparative',
'causal_chain',
'linked_spans',
'confidence',
'annotator_notes',
];
const FIELD_LABELS: Record<string, string> = {
primary_code: 'Primary Code',
secondary_codes: 'Secondary Codes',
valence: 'Valence',
intensity: 'Intensity',
specificity: 'Specificity',
actionability: 'Actionability',
temporal: 'Temporal',
evidence: 'Evidence',
comparative: 'Comparative',
causal_chain: 'Causal Chain',
linked_spans: 'Linked Spans',
confidence: 'Confidence',
annotator_notes: 'Annotator Notes',
};
export default function ProfilesSection() {
const profiles = Object.entries(taxonomy.profiles);
const getFieldStatus = (profile: typeof taxonomy.profiles.lite, field: string) => {
if (profile.required_fields.includes(field)) return 'required';
if (profile.optional_fields.includes(field)) return 'optional';
if (profile.forbidden_fields.includes(field)) return 'forbidden';
return 'forbidden';
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-gray-100">Implementation Profiles</h2>
<p className="text-gray-400 mt-1">
4 profiles for different implementation complexity levels
</p>
</div>
{/* Profile Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4">
{profiles.map(([profileKey, profile]) => {
const colors = PROFILE_COLORS[profileKey];
return (
<div
key={profileKey}
className={`rounded-lg border ${colors.border} ${colors.bg} p-4`}
>
{/* Profile Header */}
<div className="flex items-center justify-between mb-2">
<h3 className={`font-semibold ${colors.text}`}>{profile.name}</h3>
<span className={`w-3 h-3 rounded-full ${colors.badge}`} />
</div>
{/* Stats */}
<div className="flex items-center gap-3 mb-3 text-sm">
<span className="text-gray-300">{profile.code_count} codes</span>
<span className="text-gray-600">|</span>
<span className={COMPLEXITY_COLORS[profile.complexity]}>
{profile.complexity}
</span>
</div>
<p className="text-sm text-gray-400 mb-4">{profile.use_case}</p>
{/* Code pattern */}
<div className="mb-4">
<span className="text-xs text-gray-500">Code Level:</span>
<span className="ml-2 font-mono text-sm text-gray-300">
{profile.code_type}
</span>
</div>
{/* Pattern */}
<div className="bg-gray-800/50 rounded p-2 mb-3">
<span className="text-xs text-gray-500">Pattern:</span>
<code className="block font-mono text-xs text-gray-300 mt-1">
{profile.primary_code_pattern}
</code>
</div>
{/* Secondary codes */}
<div className="text-xs text-gray-400">
{profile.secondary_codes_allowed ? (
<span>
Up to {profile.secondary_codes_max} secondary codes (tier{' '}
{profile.secondary_codes_tier})
</span>
) : (
<span className="text-gray-500">No secondary codes</span>
)}
</div>
</div>
);
})}
</div>
{/* Field Comparison Table */}
<div className="mt-8">
<h3 className="text-lg font-semibold text-gray-200 mb-4">Field Requirements by Profile</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-3 text-gray-400 font-medium">Field</th>
{profiles.map(([profileKey, profile]) => (
<th
key={profileKey}
className={`text-center py-2 px-3 ${PROFILE_COLORS[profileKey].text} font-medium`}
>
{profile.name}
</th>
))}
</tr>
</thead>
<tbody>
{ALL_FIELDS.map((field) => (
<tr key={field} className="border-b border-gray-800 hover:bg-gray-800/30">
<td className="py-2 px-3 text-gray-300">{FIELD_LABELS[field]}</td>
{profiles.map(([profileKey, profile]) => {
const status = getFieldStatus(profile, field);
return (
<td key={profileKey} className="py-2 px-3 text-center">
{status === 'required' && (
<Check className="w-4 h-4 text-green-400 mx-auto" />
)}
{status === 'optional' && (
<Minus className="w-4 h-4 text-yellow-400 mx-auto" />
)}
{status === 'forbidden' && (
<X className="w-4 h-4 text-gray-600 mx-auto" />
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex items-center gap-6 mt-4 text-xs text-gray-400">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400" />
<span>Required</span>
</div>
<div className="flex items-center gap-2">
<Minus className="w-4 h-4 text-yellow-400" />
<span>Optional</span>
</div>
<div className="flex items-center gap-2">
<X className="w-4 h-4 text-gray-600" />
<span>Not used</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import type { SelectedSubcode } from '@/lib/taxonomy/types';
import { DOMAIN_TEXT_COLORS, DOMAIN_BG_COLORS, DOMAIN_BORDER_COLORS } from '@/lib/taxonomy/types';
interface SubcodeDetailProps {
selectedSubcode: SelectedSubcode | null;
}
export default function SubcodeDetail({ selectedSubcode }: SubcodeDetailProps) {
if (!selectedSubcode) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
<div className="text-center">
<p className="text-lg">Select a subcode</p>
<p className="text-sm mt-1">Click on a subcode to view its details</p>
</div>
</div>
);
}
const { code, domainKey, domainName, categoryName, subcode } = selectedSubcode;
const textColor = DOMAIN_TEXT_COLORS[domainKey];
const bgColor = DOMAIN_BG_COLORS[domainKey];
const borderColor = DOMAIN_BORDER_COLORS[domainKey];
return (
<div className="flex flex-col h-full overflow-y-auto">
{/* Header */}
<div className={`p-4 border-b border-gray-700 ${bgColor}`}>
<h2 className={`text-xl font-bold ${textColor}`}>
{code} {subcode.name}
</h2>
<div className="flex gap-2 mt-2 text-sm text-gray-400">
<span>Domain: {domainName}</span>
<span className="text-gray-600">|</span>
<span>Category: {categoryName}</span>
</div>
</div>
{/* Content */}
<div className="flex-1 p-4 space-y-6">
{/* Definition */}
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-2">
Definition
</h3>
<p className="text-gray-200">{subcode.definition}</p>
</section>
{/* Examples */}
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
Examples
</h3>
<div className="space-y-2">
{/* Positive Example */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div>
<span className="text-xs text-green-400 uppercase font-medium">Positive</span>
<p className="text-gray-200 mt-0.5">{subcode.positive_example}</p>
</div>
</div>
{/* Negative Example */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div>
<span className="text-xs text-red-400 uppercase font-medium">Negative</span>
<p className="text-gray-200 mt-0.5">{subcode.negative_example}</p>
</div>
</div>
</div>
</section>
{/* Don't Confuse With */}
{subcode.dont_confuse_with && (
<section>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">
Don&apos;t Confuse With
</h3>
<div className={`flex items-start gap-3 p-3 rounded-lg border ${bgColor} ${borderColor}`}>
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 flex-shrink-0" />
<div>
<span className={`font-mono font-medium ${textColor}`}>
{subcode.dont_confuse_with}
</span>
<p className="text-gray-300 mt-1 text-sm">{subcode.dont_confuse_reason}</p>
</div>
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { Search, X } from 'lucide-react';
import { useCallback, useState, useEffect } from 'react';
interface TaxonomySearchProps {
value: string;
onChange: (value: string) => void;
resultCount?: number;
}
export default function TaxonomySearch({ value, onChange, resultCount }: TaxonomySearchProps) {
const [localValue, setLocalValue] = useState(value);
// Debounce the search
useEffect(() => {
const timer = setTimeout(() => {
onChange(localValue);
}, 200);
return () => clearTimeout(timer);
}, [localValue, onChange]);
// Sync external changes
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleClear = useCallback(() => {
setLocalValue('');
onChange('');
}, [onChange]);
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder="Search codes..."
className="w-full bg-gray-800 border border-gray-700 rounded-lg pl-9 pr-9 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
/>
{localValue && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{value && resultCount !== undefined && (
<div className="absolute right-0 top-full mt-1 text-xs text-gray-500">
{resultCount} {resultCount === 1 ? 'match' : 'matches'}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import TreeNode from './TreeNode';
import { taxonomy, getDomainSubcodeCount, getSubcodeCount } from '@/lib/taxonomy/data';
import type { SelectedSubcode } from '@/lib/taxonomy/types';
interface TaxonomyTreeProps {
searchQuery: string;
searchResults: {
domains: string[];
categories: string[];
subcodes: string[];
};
selectedSubcode: SelectedSubcode | null;
onSelectSubcode: (subcode: SelectedSubcode | null) => void;
}
export default function TaxonomyTree({
searchQuery,
searchResults,
selectedSubcode,
onSelectSubcode,
}: TaxonomyTreeProps) {
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
// Auto-expand nodes when search results change
useEffect(() => {
if (searchQuery) {
const domainsToExpand = new Set<string>();
const categoriesToExpand = new Set<string>();
// Expand domains that have matches
searchResults.domains.forEach((d) => domainsToExpand.add(d));
// Expand parent domains for matched categories
searchResults.categories.forEach((c) => {
domainsToExpand.add(c[0]);
categoriesToExpand.add(c);
});
// Expand parent domains and categories for matched subcodes
searchResults.subcodes.forEach((s) => {
const domainKey = s[0];
const categoryKey = s.slice(0, 2);
domainsToExpand.add(domainKey);
categoriesToExpand.add(categoryKey);
});
setExpandedDomains(domainsToExpand);
setExpandedCategories(categoriesToExpand);
}
}, [searchQuery, searchResults]);
const toggleDomain = useCallback((domainKey: string) => {
setExpandedDomains((prev) => {
const next = new Set(prev);
if (next.has(domainKey)) {
next.delete(domainKey);
} else {
next.add(domainKey);
}
return next;
});
}, []);
const toggleCategory = useCallback((categoryKey: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(categoryKey)) {
next.delete(categoryKey);
} else {
next.add(categoryKey);
}
return next;
});
}, []);
const handleSelectSubcode = (
subcodeKey: string,
domainKey: string,
domainName: string,
categoryKey: string,
categoryName: string,
subcode: SelectedSubcode['subcode']
) => {
onSelectSubcode({
code: subcodeKey,
domainKey,
domainName,
categoryKey,
categoryName,
subcode,
});
};
const isSubcodeSelected = (subcodeKey: string) => {
return selectedSubcode?.code === subcodeKey;
};
const isSearchMatch = (key: string) => {
if (!searchQuery) return false;
return (
searchResults.domains.includes(key) ||
searchResults.categories.includes(key) ||
searchResults.subcodes.includes(key)
);
};
// Filter to show only matches when searching
const shouldShowDomain = (domainKey: string) => {
if (!searchQuery) return true;
// Show domain if it matches or any of its children match
if (searchResults.domains.includes(domainKey)) return true;
if (searchResults.categories.some((c) => c.startsWith(domainKey))) return true;
if (searchResults.subcodes.some((s) => s.startsWith(domainKey))) return true;
return false;
};
const shouldShowCategory = (categoryKey: string) => {
if (!searchQuery) return true;
if (searchResults.categories.includes(categoryKey)) return true;
if (searchResults.subcodes.some((s) => s.startsWith(categoryKey))) return true;
return false;
};
const shouldShowSubcode = (subcodeKey: string) => {
if (!searchQuery) return true;
return searchResults.subcodes.includes(subcodeKey);
};
return (
<div className="flex flex-col gap-0.5">
{Object.entries(taxonomy.domains).map(([domainKey, domain]) => {
if (!shouldShowDomain(domainKey)) return null;
return (
<TreeNode
key={domainKey}
code={domainKey}
name={domain.name}
count={getDomainSubcodeCount(domainKey)}
isExpanded={expandedDomains.has(domainKey)}
level="domain"
domainKey={domainKey}
onToggle={() => toggleDomain(domainKey)}
searchMatch={isSearchMatch(domainKey)}
>
{Object.entries(domain.categories).map(([categoryKey, category]) => {
if (!shouldShowCategory(categoryKey)) return null;
return (
<TreeNode
key={categoryKey}
code={categoryKey}
name={category.name}
count={getSubcodeCount(categoryKey)}
isExpanded={expandedCategories.has(categoryKey)}
level="category"
domainKey={domainKey}
onToggle={() => toggleCategory(categoryKey)}
searchMatch={isSearchMatch(categoryKey)}
>
{Object.entries(category.subcodes).map(([subcodeKey, subcode]) => {
if (!shouldShowSubcode(subcodeKey)) return null;
return (
<TreeNode
key={subcodeKey}
code={subcodeKey}
name={subcode.name}
isLeaf
isSelected={isSubcodeSelected(subcodeKey)}
level="subcode"
domainKey={domainKey}
onClick={() =>
handleSelectSubcode(
subcodeKey,
domainKey,
domain.name,
categoryKey,
category.name,
subcode
)
}
searchMatch={isSearchMatch(subcodeKey)}
/>
);
})}
</TreeNode>
);
})}
</TreeNode>
);
})}
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { DOMAIN_TEXT_COLORS } from '@/lib/taxonomy/types';
interface TreeNodeProps {
code: string;
name: string;
count?: number;
isExpanded?: boolean;
isSelected?: boolean;
isLeaf?: boolean;
level: 'domain' | 'category' | 'subcode';
domainKey: string;
onToggle?: () => void;
onClick?: () => void;
searchMatch?: boolean;
children?: React.ReactNode;
}
export default function TreeNode({
code,
name,
count,
isExpanded = false,
isSelected = false,
isLeaf = false,
level,
domainKey,
onToggle,
onClick,
searchMatch = false,
children,
}: TreeNodeProps) {
const textColor = DOMAIN_TEXT_COLORS[domainKey] || 'text-gray-400';
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isLeaf) {
onClick?.();
} else {
onToggle?.();
}
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggle?.();
};
const getPadding = () => {
switch (level) {
case 'domain':
return 'pl-2';
case 'category':
return 'pl-6';
case 'subcode':
return 'pl-10';
default:
return 'pl-2';
}
};
return (
<div>
<div
onClick={handleClick}
className={`
flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer transition-colors
${getPadding()}
${isSelected ? 'bg-gray-700' : 'hover:bg-gray-800/50'}
${searchMatch ? 'ring-1 ring-yellow-500/50' : ''}
`}
>
{/* Expand/Collapse Icon */}
{!isLeaf ? (
<button
onClick={handleChevronClick}
className="w-4 h-4 flex items-center justify-center text-gray-500 hover:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
) : (
<span className="w-4 h-4 flex items-center justify-center text-gray-600">
<span className="w-1.5 h-1.5 rounded-full bg-current" />
</span>
)}
{/* Code */}
<span className={`font-mono text-sm ${textColor}`}>{code}</span>
{/* Name */}
<span className="text-sm text-gray-300 truncate flex-1">{name}</span>
{/* Count Badge */}
{count !== undefined && (
<span className="text-xs text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded">
{count}
</span>
)}
</div>
{/* Children (expanded content) */}
{isExpanded && children && <div>{children}</div>}
</div>
);
}