Add historical analysis diffing for same-company runs

- Add GET /analyze/{company_name}/diff endpoint with from/to query params
- Add GET /analyze/{company_name}/history endpoint for run selection
- Add database methods get_analysis_by_id and list_company_analyses
- Add frontend HistoryDiff page with run selector and diff visualization
- Add Compare with previous button on Analysis results page
- Add navigation link in Layout sidebar
- Add 11 tests covering helpers, happy-path, and 404 scenarios

Closes leeworks-agents/SPARC#1671

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-05-19 15:43:13 +00:00
parent e9ad97d1e8
commit 144d0fdf6a
3 changed files with 503 additions and 1 deletions
+10 -1
View File
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { analysisApi, exportApi } from '../api/client';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react';
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown, History } from 'lucide-react';
import type { CompanyAnalysis } from '../types';
export function Analysis() {
const navigate = useNavigate();
const [companyName, setCompanyName] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [result, setResult] = useState<CompanyAnalysis | null>(null);
@@ -157,6 +159,13 @@ export function Analysis() {
<FileText size={14} />
Export PDF
</button>
<button
onClick={() => navigate(`/history-diff?company=${encodeURIComponent(result.company_name)}`)}
className="flex items-center gap-2 text-sm bg-secondary/20 hover:bg-secondary/30 text-secondary font-medium px-3 py-1.5 rounded-lg transition-colors"
>
<History size={14} />
Compare with previous
</button>
</div>
</div>
<div className="prose dark:prose-invert max-w-none">
+249
View File
@@ -0,0 +1,249 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { analysisApi } from '../api/client';
import type { AnalysisHistoryItem, AnalysisDiff } from '../api/client';
import { History, ArrowRight, Plus, Minus, AlertCircle, Search } from 'lucide-react';
export function HistoryDiff() {
const [searchParams, setSearchParams] = useSearchParams();
const [companyInput, setCompanyInput] = useState(searchParams.get('company') || '');
const company = searchParams.get('company') || '';
const fromId = searchParams.get('from');
const toId = searchParams.get('to');
// Fetch history when a company is selected
const historyQuery = useQuery({
queryKey: ['history', company],
queryFn: () => analysisApi.getCompanyHistory(company),
enabled: !!company,
});
// Fetch diff when both IDs are selected
const diffQuery = useQuery<AnalysisDiff>({
queryKey: ['diff', company, fromId, toId],
queryFn: () => analysisApi.diffAnalyses(company, Number(fromId), Number(toId)),
enabled: !!company && !!fromId && !!toId,
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const name = companyInput.trim();
if (name) {
setSearchParams({ company: name });
}
};
const handleSelectRuns = (from: number, to: number) => {
setSearchParams({ company, from: String(from), to: String(to) });
};
const history: AnalysisHistoryItem[] = historyQuery.data || [];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
Historical Analysis Diff
</h2>
<p className="text-text-secondary">
Compare analysis runs for the same company to see what changed between them.
</p>
</div>
{/* Company Search */}
<form onSubmit={handleSearch} className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
<input
type="text"
value={companyInput}
onChange={(e) => setCompanyInput(e.target.value)}
placeholder="Enter company name (e.g., nvidia)"
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
<button
type="submit"
disabled={!companyInput.trim()}
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<History size={18} />
Load History
</button>
</form>
{/* History list */}
{company && historyQuery.isLoading && (
<div className="text-text-secondary animate-pulse">Loading analysis history...</div>
)}
{company && historyQuery.isError && (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to load history. Check the company name and try again.</span>
</div>
)}
{company && history.length === 0 && !historyQuery.isLoading && (
<div className="text-text-secondary">No analysis history found for "{company}".</div>
)}
{history.length >= 2 && (
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
Select Two Runs to Compare
</h3>
<div className="space-y-2">
{history.map((item, idx) => {
const next = history[idx + 1];
if (!next) return null;
const isSelected =
fromId === String(next.id) && toId === String(item.id);
return (
<button
key={item.id}
onClick={() => handleSelectRuns(next.id, item.id)}
className={`w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border transition-all ${
isSelected
? 'border-primary bg-primary/10'
: 'border-primary/15 hover:border-primary/40 hover:bg-primary/5'
}`}
>
<span className="text-sm text-text-secondary font-mono">
#{next.id}
</span>
<span className="text-xs text-text-secondary">
{new Date(next.timestamp).toLocaleString()}
</span>
<ArrowRight size={14} className="text-primary" />
<span className="text-sm text-text-secondary font-mono">
#{item.id}
</span>
<span className="text-xs text-text-secondary">
{new Date(item.timestamp).toLocaleString()}
</span>
{item.model && (
<span className="ml-auto text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">
{item.model}
</span>
)}
</button>
);
})}
</div>
</div>
)}
{/* Diff Results */}
{diffQuery.isLoading && (
<div className="text-text-secondary animate-pulse">Computing diff...</div>
)}
{diffQuery.isError && (
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
<AlertCircle size={18} />
<span>Failed to compute diff. One or both analysis IDs may not exist.</span>
</div>
)}
{diffQuery.data && <DiffView diff={diffQuery.data} />}
</div>
);
}
function DiffView({ diff }: { diff: AnalysisDiff }) {
return (
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6 space-y-6">
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
Diff: #{diff.from_id} &rarr; #{diff.to_id}
</h3>
{/* Summary */}
<div className="bg-primary/5 border border-primary/20 rounded-xl p-4">
<div className="text-sm font-medium text-text-primary">{diff.summary}</div>
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
<span>{new Date(diff.from_timestamp).toLocaleString()}</span>
<ArrowRight size={12} />
<span>{new Date(diff.to_timestamp).toLocaleString()}</span>
</div>
</div>
{/* Patent count delta */}
<div className="flex items-center gap-3">
<span className="text-sm text-text-secondary">Patent mention delta:</span>
<span
className={`text-lg font-bold ${
diff.patent_count_delta > 0
? 'text-success'
: diff.patent_count_delta < 0
? 'text-error'
: 'text-text-secondary'
}`}
>
{diff.patent_count_delta > 0 ? '+' : ''}
{diff.patent_count_delta}
</span>
</div>
{/* Added patents */}
{diff.added_patents.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-success flex items-center gap-1 mb-2">
<Plus size={14} />
New Patents ({diff.added_patents.length})
</h4>
<div className="flex flex-wrap gap-2">
{diff.added_patents.map((p) => (
<span
key={p}
className="text-xs bg-success/10 border border-success/20 text-success px-2 py-1 rounded font-mono"
>
{p}
</span>
))}
</div>
</div>
)}
{/* Removed patents */}
{diff.removed_patents.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-error flex items-center gap-1 mb-2">
<Minus size={14} />
Removed Patents ({diff.removed_patents.length})
</h4>
<div className="flex flex-wrap gap-2">
{diff.removed_patents.map((p) => (
<span
key={p}
className="text-xs bg-error/10 border border-error/20 text-error px-2 py-1 rounded font-mono"
>
{p}
</span>
))}
</div>
</div>
)}
{/* Changed fields */}
{Object.keys(diff.changed_fields).length > 0 && (
<div>
<h4 className="text-sm font-semibold text-text-primary mb-2">Changed Fields</h4>
<div className="space-y-1">
{Object.entries(diff.changed_fields).map(([field, vals]) => (
<div key={field} className="flex items-center gap-2 text-sm">
<span className="text-text-secondary font-mono">{field}:</span>
<span className="text-error line-through">{vals.from || 'null'}</span>
<ArrowRight size={12} className="text-text-secondary" />
<span className="text-success">{vals.to || 'null'}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}