forked from 0xWheatyz/SPARC
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f8f7fa79 | |||
| f0edc5a3ae | |||
| f64d1b745f | |||
| 513b682dad | |||
| 52972bbff0 | |||
| c738f785c3 |
@@ -453,6 +453,78 @@ async def get_analytics(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/analytics/trends", tags=["Analytics"])
|
||||
async def get_analytics_trends(
|
||||
days: int = Query(default=90, ge=7, le=365),
|
||||
_: UserResponse = Depends(get_current_user),
|
||||
):
|
||||
"""Get trend data for patent analysis over time.
|
||||
|
||||
Returns two datasets:
|
||||
- ``by_month``: analysis count per company per month
|
||||
- ``by_type_over_time``: analysis type distribution per month
|
||||
|
||||
Args:
|
||||
days: Number of days to look back (default 90)
|
||||
|
||||
Returns:
|
||||
Trend data suitable for time-series and distribution charts
|
||||
"""
|
||||
db = get_db_client()
|
||||
|
||||
with db.get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Analyses per company per month
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||
company_name,
|
||||
COUNT(*) AS count
|
||||
FROM llm_messages
|
||||
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||
AND is_cached = FALSE
|
||||
AND company_name IS NOT NULL
|
||||
GROUP BY month, company_name
|
||||
ORDER BY month
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
by_month_rows = cur.fetchall()
|
||||
|
||||
# Analysis type distribution per month
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
TO_CHAR(timestamp, 'YYYY-MM') AS month,
|
||||
analysis_type,
|
||||
COUNT(*) AS count
|
||||
FROM llm_messages
|
||||
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||
AND is_cached = FALSE
|
||||
GROUP BY month, analysis_type
|
||||
ORDER BY month
|
||||
""",
|
||||
(days,),
|
||||
)
|
||||
by_type_rows = cur.fetchall()
|
||||
|
||||
by_month = [
|
||||
{"month": row[0], "company_name": row[1], "count": row[2]}
|
||||
for row in by_month_rows
|
||||
]
|
||||
by_type_over_time = [
|
||||
{"month": row[0], "analysis_type": row[1], "count": row[2]}
|
||||
for row in by_type_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"by_month": by_month,
|
||||
"by_type_over_time": by_type_over_time,
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
|
||||
# ============== Export Endpoints ==============
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Batch } from './pages/Batch';
|
||||
import { AnalyticsPage } from './pages/Analytics';
|
||||
import { About } from './pages/About';
|
||||
import { AdminUsers } from './pages/AdminUsers';
|
||||
import { Compare } from './pages/Compare';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -43,6 +44,7 @@ function App() {
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/batch" element={<Batch />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
|
||||
@@ -144,11 +144,22 @@ export const exportApi = {
|
||||
};
|
||||
|
||||
// Analytics API
|
||||
export interface TrendData {
|
||||
by_month: Array<{ month: string; company_name: string; count: number }>;
|
||||
by_type_over_time: Array<{ month: string; analysis_type: string; count: number }>;
|
||||
period_days: number;
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrends: async (days = 90): Promise<TrendData> => {
|
||||
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Admin API
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { Search, Layers, BarChart3, Info, Users, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react';
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
@@ -17,6 +17,7 @@ export function Layout() {
|
||||
{ to: '/analysis', icon: Search, label: 'Analysis' },
|
||||
{ to: '/batch', icon: Layers, label: 'Batch' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/compare', icon: GitCompareArrows, label: 'Compare' },
|
||||
{ to: '/about', icon: Info, label: 'About' },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analyticsApi } from '../api/client';
|
||||
import { AlertCircle, Database } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
|
||||
@@ -14,6 +14,11 @@ export function AnalyticsPage() {
|
||||
queryFn: () => analyticsApi.getAnalytics(days),
|
||||
});
|
||||
|
||||
const trendsQuery = useQuery({
|
||||
queryKey: ['analytics-trends', days],
|
||||
queryFn: () => analyticsApi.getTrends(days),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -189,6 +194,114 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend Charts */}
|
||||
{trendsQuery.data && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
Trends Over Time
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Patent count over time per company (line chart) */}
|
||||
{trendsQuery.data.by_month.length > 0 && (() => {
|
||||
// Pivot data: each month as a row, companies as columns
|
||||
const companies = [...new Set(trendsQuery.data!.by_month.map(d => d.company_name))];
|
||||
const months = [...new Set(trendsQuery.data!.by_month.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const c of companies) {
|
||||
const entry = trendsQuery.data!.by_month.find(d => d.month === month && d.company_name === c);
|
||||
row[c] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analyses per Company Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Legend />
|
||||
{companies.map((company, idx) => (
|
||||
<Line
|
||||
key={company}
|
||||
type="monotone"
|
||||
dataKey={company}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
name={company.toUpperCase()}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Analysis type distribution over time (stacked bar) */}
|
||||
{trendsQuery.data.by_type_over_time.length > 0 && (() => {
|
||||
const types = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.analysis_type))];
|
||||
const months = [...new Set(trendsQuery.data!.by_type_over_time.map(d => d.month))].sort();
|
||||
const pivoted = months.map(month => {
|
||||
const row: Record<string, string | number> = { month };
|
||||
for (const t of types) {
|
||||
const entry = trendsQuery.data!.by_type_over_time.find(d => d.month === month && d.analysis_type === t);
|
||||
row[t] = entry?.count || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6">
|
||||
<h4 className="text-md font-semibold text-text-primary mb-4">Analysis Types Over Time</h4>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={pivoted}>
|
||||
<XAxis dataKey="month" stroke="#94a3b8" fontSize={12} />
|
||||
<YAxis stroke="#94a3b8" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid rgba(99, 102, 241, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
labelStyle={{ color: '#f8fafc' }}
|
||||
/>
|
||||
<Legend />
|
||||
{types.map((type, idx) => (
|
||||
<Bar
|
||||
key={type}
|
||||
dataKey={type}
|
||||
stackId="types"
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
name={type}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{trendsQuery.data.by_month.length === 0 && (
|
||||
<div className="text-text-secondary text-center py-8">
|
||||
No trend data available yet. Run analyses over multiple days to see trends.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi } from '../api/client';
|
||||
import { GitCompareArrows, AlertCircle, FileText, Clock } from 'lucide-react';
|
||||
import type { CompanyAnalysis } from '../types';
|
||||
|
||||
function CompanyPanel({ data, isLoading, isError }: { data?: CompanyAnalysis; isLoading: boolean; isError: boolean }) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 animate-pulse">
|
||||
<div className="h-6 w-32 bg-primary/20 rounded mb-4" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-primary/10 rounded w-full" />
|
||||
<div className="h-4 bg-primary/10 rounded w-3/4" />
|
||||
<div className="h-4 bg-primary/10 rounded w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="bg-error/10 border border-error/20 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 text-error">
|
||||
<AlertCircle size={18} />
|
||||
<span>Failed to load analysis. Check the company name and try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card/60 border border-primary/15 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="text-lg font-bold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
{data.company_name.toUpperCase()}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||
<FileText className="mx-auto mb-1 text-primary" size={18} />
|
||||
<div className="text-xl font-bold text-text-primary">{data.patent_count}</div>
|
||||
<div className="text-xs text-text-secondary uppercase">Patents</div>
|
||||
</div>
|
||||
<div className="bg-primary/10 rounded-lg p-3 text-center">
|
||||
<Clock className="mx-auto mb-1 text-primary" size={18} />
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{new Date(data.timestamp).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary uppercase">Analyzed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.success && data.analysis ? (
|
||||
<div className="text-text-primary whitespace-pre-wrap leading-relaxed text-sm">
|
||||
{data.analysis}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-error text-sm">{data.error || 'Analysis not available'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Compare() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [companyA, setCompanyA] = useState(searchParams.get('a') || '');
|
||||
const [companyB, setCompanyB] = useState(searchParams.get('b') || '');
|
||||
|
||||
const queryA = searchParams.get('a') || '';
|
||||
const queryB = searchParams.get('b') || '';
|
||||
|
||||
const resultA = useQuery({
|
||||
queryKey: ['analyze', queryA],
|
||||
queryFn: () => analysisApi.analyzeCompany(queryA),
|
||||
enabled: !!queryA,
|
||||
});
|
||||
|
||||
const resultB = useQuery({
|
||||
queryKey: ['analyze', queryB],
|
||||
queryFn: () => analysisApi.analyzeCompany(queryB),
|
||||
enabled: !!queryB,
|
||||
});
|
||||
|
||||
const handleCompare = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const a = companyA.trim();
|
||||
const b = companyB.trim();
|
||||
if (a && b) {
|
||||
setSearchParams({ a, b });
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
Portfolio Comparison
|
||||
</h2>
|
||||
<p className="text-text-secondary">
|
||||
Compare patent portfolios of two companies side by side.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input Form */}
|
||||
<form onSubmit={handleCompare} className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Company A</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyA}
|
||||
onChange={(e) => setCompanyA(e.target.value)}
|
||||
placeholder="e.g. nvidia"
|
||||
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Company B</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyB}
|
||||
onChange={(e) => setCompanyB(e.target.value)}
|
||||
placeholder="e.g. intel"
|
||||
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl px-4 py-2.5 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={!companyA.trim() || !companyB.trim() || resultA.isLoading || resultB.isLoading}
|
||||
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-2.5 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"
|
||||
>
|
||||
<GitCompareArrows size={18} />
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Comparison Panels */}
|
||||
{(queryA || queryB) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{queryA && (
|
||||
<CompanyPanel
|
||||
data={resultA.data}
|
||||
isLoading={resultA.isLoading}
|
||||
isError={resultA.isError}
|
||||
/>
|
||||
)}
|
||||
{queryB && (
|
||||
<CompanyPanel
|
||||
data={resultB.data}
|
||||
isLoading={resultB.isLoading}
|
||||
isError={resultB.isError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user