f05bbba613
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 21s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
P1 (backend/data_loader.py): Add load_latest_school_data() which pre-computes the one-row-per-school latest-year snapshot (groupby, prev-year trend merge) once at startup instead of on every /api/schools request. get_schools route now starts from the cached snapshot rather than rebuilding it. S3 (backend/app.py): Wrap synchronous geocode_single_postcode() call in asyncio.to_thread() so postcode lookups no longer block the uvicorn event loop. Admin reload endpoint also uses to_thread for both cache primes. P2 (nextjs-app/components/HomeView.tsx): Add mapParamsRef guard so switching back to map view does not re-fetch 500 schools when search params haven't changed. Reset ref on new searches so fresh data is always fetched. P3 (nextjs-app/lib/chartSetup.ts): Extract Chart.js registration into a shared side-effect module. ComparisonChart and PerformanceChart now import it instead of each calling ChartJS.register() independently. P4 (backend/database.py): Remove unnecessary db.commit() from the read-only get_db_session() context manager — saves a DB round-trip on every request. P5 (backend/database.py): Add pool_recycle=1800 to SQLAlchemy engine to prevent stale TCP connections from accumulating in long-running processes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
3.9 KiB
TypeScript
157 lines
3.9 KiB
TypeScript
/**
|
|
* ComparisonChart Component
|
|
* Multi-school comparison chart using Chart.js
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { Line } from 'react-chartjs-2';
|
|
import { ChartOptions } from 'chart.js';
|
|
import '@/lib/chartSetup';
|
|
import type { ComparisonData } from '@/lib/types';
|
|
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
|
|
|
interface ComparisonChartProps {
|
|
comparisonData: Record<string, ComparisonData>;
|
|
metric: string;
|
|
metricLabel: string;
|
|
}
|
|
|
|
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
|
|
// Get all schools and their data
|
|
const schools = Object.entries(comparisonData);
|
|
|
|
if (schools.length === 0) {
|
|
return <div>No data available</div>;
|
|
}
|
|
|
|
// Get years from first school (assuming all schools have same years)
|
|
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
|
|
|
|
// Create datasets for each school
|
|
const datasets = schools.map(([urn, data], index) => {
|
|
const schoolInfo = data.school_info;
|
|
const color = CHART_COLORS[index % CHART_COLORS.length];
|
|
|
|
return {
|
|
label: schoolInfo.school_name,
|
|
data: years.map((year) => {
|
|
const yearData = data.yearly_data.find((d) => d.year === year);
|
|
if (!yearData) return null;
|
|
return yearData[metric as keyof typeof yearData] as number | null;
|
|
}),
|
|
borderColor: color,
|
|
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
|
tension: 0.3,
|
|
spanGaps: true,
|
|
};
|
|
});
|
|
|
|
const chartData = {
|
|
labels: years.map(formatAcademicYear),
|
|
datasets,
|
|
};
|
|
|
|
// Determine if metric is a progress score or percentage
|
|
const isProgressScore = metric.includes('progress');
|
|
const isPercentage = metric.includes('pct') || metric.includes('rate');
|
|
|
|
const options: ChartOptions<'line'> = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
mode: 'index' as const,
|
|
intersect: false,
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top' as const,
|
|
labels: {
|
|
usePointStyle: true,
|
|
padding: 15,
|
|
font: {
|
|
size: 12,
|
|
},
|
|
},
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: `${metricLabel} - Comparison`,
|
|
font: {
|
|
size: 16,
|
|
weight: 'bold',
|
|
},
|
|
padding: {
|
|
bottom: 20,
|
|
},
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
padding: 12,
|
|
titleFont: {
|
|
size: 14,
|
|
},
|
|
bodyFont: {
|
|
size: 13,
|
|
},
|
|
callbacks: {
|
|
label: function (context) {
|
|
let label = context.dataset.label || '';
|
|
if (label) {
|
|
label += ': ';
|
|
}
|
|
if (context.parsed.y !== null) {
|
|
if (isProgressScore) {
|
|
label += context.parsed.y.toFixed(1);
|
|
} else if (isPercentage) {
|
|
label += context.parsed.y.toFixed(1) + '%';
|
|
} else {
|
|
label += context.parsed.y.toFixed(1);
|
|
}
|
|
} else {
|
|
label += 'N/A';
|
|
}
|
|
return label;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
y: {
|
|
type: 'linear' as const,
|
|
display: true,
|
|
title: {
|
|
display: true,
|
|
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
|
|
font: {
|
|
size: 12,
|
|
weight: 'bold',
|
|
},
|
|
},
|
|
...(isPercentage && {
|
|
min: 0,
|
|
max: 100,
|
|
}),
|
|
grid: {
|
|
color: 'rgba(0, 0, 0, 0.05)',
|
|
},
|
|
},
|
|
x: {
|
|
grid: {
|
|
display: false,
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: 'Year',
|
|
font: {
|
|
size: 12,
|
|
weight: 'bold',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
return <Line data={chartData} options={options} />;
|
|
}
|