Complete Next.js migration with SSR and Docker deployment
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router - Add server-side rendering for all pages (Home, Compare, Rankings) - Create individual school pages with dynamic routing (/school/[urn]) - Implement Chart.js and Leaflet map integrations - Add comprehensive SEO with sitemap, robots.txt, and JSON-LD - Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js) - Update CI/CD pipeline to build both backend and frontend images - Fix Dockerfile to include devDependencies for TypeScript compilation - Add Jest testing configuration - Implement performance optimizations (code splitting, caching) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
229
nextjs-app/components/RankingsView.tsx
Normal file
229
nextjs-app/components/RankingsView.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* RankingsView Component
|
||||
* Client-side rankings interface with filters
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useComparison } from '@/hooks/useComparison';
|
||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress } from '@/lib/utils';
|
||||
import styles from './RankingsView.module.css';
|
||||
|
||||
interface RankingsViewProps {
|
||||
rankings: RankingEntry[];
|
||||
filters: Filters;
|
||||
metrics: MetricDefinition[];
|
||||
selectedMetric: string;
|
||||
selectedArea?: string;
|
||||
selectedYear?: number;
|
||||
}
|
||||
|
||||
export function RankingsView({
|
||||
rankings,
|
||||
filters,
|
||||
metrics,
|
||||
selectedMetric,
|
||||
selectedArea,
|
||||
selectedYear,
|
||||
}: RankingsViewProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { addSchool, isSelected } = useComparison();
|
||||
|
||||
const updateFilters = (updates: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
updateFilters({ metric });
|
||||
};
|
||||
|
||||
const handleAreaChange = (area: string) => {
|
||||
updateFilters({ local_authority: area || undefined });
|
||||
};
|
||||
|
||||
const handleYearChange = (year: string) => {
|
||||
updateFilters({ year: year || undefined });
|
||||
};
|
||||
|
||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||
addSchool({
|
||||
...ranking,
|
||||
// Ensure required School fields are present
|
||||
address: null,
|
||||
postcode: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
} as any);
|
||||
};
|
||||
|
||||
// Get metric definition
|
||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||||
const isProgressScore = selectedMetric.includes('progress');
|
||||
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
<header className={styles.header}>
|
||||
<h1>School Rankings</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Top-performing schools by {metricLabel.toLowerCase()}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<section className={styles.filters}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label htmlFor="metric-select" className={styles.filterLabel}>
|
||||
Metric:
|
||||
</label>
|
||||
<select
|
||||
id="metric-select"
|
||||
value={selectedMetric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>
|
||||
{metric.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label htmlFor="area-select" className={styles.filterLabel}>
|
||||
Area:
|
||||
</label>
|
||||
<select
|
||||
id="area-select"
|
||||
value={selectedArea || ''}
|
||||
onChange={(e) => handleAreaChange(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
{filters.local_authorities.map((area) => (
|
||||
<option key={area} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label htmlFor="year-select" className={styles.filterLabel}>
|
||||
Year:
|
||||
</label>
|
||||
<select
|
||||
id="year-select"
|
||||
value={selectedYear?.toString() || ''}
|
||||
onChange={(e) => handleYearChange(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
<option value="">Latest</option>
|
||||
{filters.years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Rankings Table */}
|
||||
<section className={styles.rankingsSection}>
|
||||
{rankings.length === 0 ? (
|
||||
<div className={styles.noResults}>
|
||||
<p>No rankings available for the selected filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.rankingsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.rankHeader}>Rank</th>
|
||||
<th className={styles.schoolHeader}>School</th>
|
||||
<th className={styles.areaHeader}>Area</th>
|
||||
<th className={styles.typeHeader}>Type</th>
|
||||
<th className={styles.valueHeader}>{metricLabel}</th>
|
||||
<th className={styles.actionHeader}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankings.map((ranking, index) => {
|
||||
const rank = index + 1;
|
||||
const isTopThree = rank <= 3;
|
||||
const alreadyInComparison = isSelected(ranking.urn);
|
||||
|
||||
// Format the value
|
||||
let displayValue: string;
|
||||
if (ranking.value === null || ranking.value === undefined) {
|
||||
displayValue = '-';
|
||||
} else if (isProgressScore) {
|
||||
displayValue = formatProgress(ranking.value);
|
||||
} else if (isPercentage) {
|
||||
displayValue = formatPercentage(ranking.value);
|
||||
} else {
|
||||
displayValue = ranking.value.toFixed(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={ranking.urn}
|
||||
className={isTopThree ? styles[`rank${rank}`] : ''}
|
||||
>
|
||||
<td className={styles.rankCell}>
|
||||
{isTopThree && (
|
||||
<span className={styles.medal}>
|
||||
{rank === 1 && '🥇'}
|
||||
{rank === 2 && '🥈'}
|
||||
{rank === 3 && '🥉'}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.rankNumber}>{rank}</span>
|
||||
</td>
|
||||
<td className={styles.schoolCell}>
|
||||
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
|
||||
{ranking.school_name}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
|
||||
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
|
||||
<td className={styles.valueCell}>
|
||||
<strong>{displayValue}</strong>
|
||||
</td>
|
||||
<td className={styles.actionCell}>
|
||||
<button
|
||||
onClick={() => handleAddToCompare(ranking)}
|
||||
disabled={alreadyInComparison}
|
||||
className={styles.addButton}
|
||||
>
|
||||
{alreadyInComparison ? '✓ Added' : '+ Add'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user