forked from 0xWheatyz/SPARC
feat: add patent trend charts to the Analytics page
Add GET /analytics/trends endpoint returning per-company analysis counts by month and analysis type distribution over time. Render these as a line chart (analyses per company) and stacked bar chart (analysis types) on the Analytics page using recharts. Closes leeworks-agents/SPARC#24 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -389,6 +389,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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============== System Endpoints ==============
|
# ============== System Endpoints ==============
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -127,11 +127,22 @@ export const analysisApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Analytics API
|
// 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 = {
|
export const analyticsApi = {
|
||||||
getAnalytics: async (days = 30): Promise<Analytics> => {
|
getAnalytics: async (days = 30): Promise<Analytics> => {
|
||||||
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
const response = await api.get<Analytics>(`/analytics?days=${days}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTrends: async (days = 90): Promise<TrendData> => {
|
||||||
|
const response = await api.get<TrendData>(`/analytics/trends?days=${days}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { analyticsApi } from '../api/client';
|
import { analyticsApi } from '../api/client';
|
||||||
import { AlertCircle, Database } from 'lucide-react';
|
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'];
|
const COLORS = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ export function AnalyticsPage() {
|
|||||||
queryFn: () => analyticsApi.getAnalytics(days),
|
queryFn: () => analyticsApi.getAnalytics(days),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trendsQuery = useQuery({
|
||||||
|
queryKey: ['analytics-trends', days],
|
||||||
|
queryFn: () => analyticsApi.getTrends(days),
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -163,6 +168,114 @@ export function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user