feat(ux): 8 UX improvements — simpler home, advanced filters, phase tabs, 4-line rows
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 48s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 48s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
1. Simpler home page: only search box on landing, no filter dropdowns 2. Advanced filters: hidden behind toggle on results page, auto-open if active 3. Per-school phase rendering: each row renders based on its own data 4. Taller 4-line rows with context line (type, age range, denomination, gender) 5. Result-scoped filters: dropdown values reflect current search results 6. Fix blank filter values: exclude empty strings and "Not applicable" 7. Rankings: Primary/Secondary phase tabs with phase-specific metrics 8. Compare: Primary/Secondary tabs with school counts and phase metrics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,17 @@ from .data_loader import get_data_info as get_db_info
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json
|
||||
|
||||
# Values to exclude from filter dropdowns (empty strings, non-applicable labels)
|
||||
EXCLUDED_FILTER_VALUES = {"", "Not applicable", "Does not apply"}
|
||||
|
||||
|
||||
def clean_filter_values(series: pd.Series) -> list[str]:
|
||||
"""Return sorted unique values from a Series, excluding NaN and junk labels."""
|
||||
return sorted(
|
||||
v for v in series.dropna().unique().tolist()
|
||||
if v not in EXCLUDED_FILTER_VALUES
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY MIDDLEWARE & HELPERS
|
||||
@@ -380,6 +391,15 @@ async def get_schools(
|
||||
schools_df["school_type"].str.lower() == school_type.lower()
|
||||
]
|
||||
|
||||
# Compute result-scoped filter values (before pagination)
|
||||
result_filters = {
|
||||
"local_authorities": clean_filter_values(schools_df["local_authority"]) if "local_authority" in schools_df.columns else [],
|
||||
"school_types": clean_filter_values(schools_df["school_type"]) if "school_type" in schools_df.columns else [],
|
||||
"phases": clean_filter_values(schools_df["phase"]) if "phase" in schools_df.columns else [],
|
||||
"genders": clean_filter_values(schools_df["gender"]) if "gender" in schools_df.columns else [],
|
||||
"admissions_policies": clean_filter_values(schools_df["admissions_policy"]) if "admissions_policy" in schools_df.columns else [],
|
||||
}
|
||||
|
||||
# Pagination
|
||||
total = len(schools_df)
|
||||
start_idx = (page - 1) * page_size
|
||||
@@ -392,6 +412,7 @@ async def get_schools(
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||
"result_filters": result_filters,
|
||||
"location_info": {
|
||||
"postcode": postcode,
|
||||
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
||||
@@ -507,7 +528,11 @@ async def compare_schools(
|
||||
"urn": urn,
|
||||
"school_name": latest.get("school_name", ""),
|
||||
"local_authority": latest.get("local_authority", ""),
|
||||
"school_type": latest.get("school_type", ""),
|
||||
"address": latest.get("address", ""),
|
||||
"phase": latest.get("phase", ""),
|
||||
"attainment_8_score": float(latest["attainment_8_score"]) if pd.notna(latest.get("attainment_8_score")) else None,
|
||||
"rwm_expected_pct": float(latest["rwm_expected_pct"]) if pd.notna(latest.get("rwm_expected_pct")) else None,
|
||||
},
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
}
|
||||
@@ -529,15 +554,15 @@ async def get_filter_options(request: Request):
|
||||
}
|
||||
|
||||
# Phases: return values from data, ordered sensibly
|
||||
phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else []
|
||||
phases = clean_filter_values(df["phase"]) if "phase" in df.columns else []
|
||||
|
||||
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
|
||||
genders = sorted(secondary_df["gender"].dropna().unique().tolist()) if "gender" in secondary_df.columns else []
|
||||
admissions_policies = sorted(secondary_df["admissions_policy"].dropna().unique().tolist()) if "admissions_policy" in secondary_df.columns else []
|
||||
genders = clean_filter_values(secondary_df["gender"]) if "gender" in secondary_df.columns else []
|
||||
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
|
||||
|
||||
return {
|
||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
||||
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
|
||||
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
|
||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||
"phases": phases,
|
||||
"genders": genders,
|
||||
@@ -641,6 +666,9 @@ async def get_rankings(
|
||||
local_authority: Optional[str] = Query(
|
||||
None, description="Filter by local authority", max_length=100
|
||||
),
|
||||
phase: Optional[str] = Query(
|
||||
None, description="Filter by phase: primary or secondary", max_length=20
|
||||
),
|
||||
):
|
||||
"""Get school rankings by a specific metric."""
|
||||
# Sanitize local authority input
|
||||
@@ -670,6 +698,12 @@ async def get_rankings(
|
||||
if local_authority:
|
||||
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
||||
|
||||
# Filter by phase
|
||||
if phase == "primary" and "rwm_expected_pct" in df.columns:
|
||||
df = df[df["rwm_expected_pct"].notna()]
|
||||
elif phase == "secondary" and "attainment_8_score" in df.columns:
|
||||
df = df[df["attainment_8_score"].notna()]
|
||||
|
||||
# Sort and rank (exclude rows with no data for this metric)
|
||||
df = df.dropna(subset=[metric])
|
||||
total = len(df)
|
||||
|
||||
@@ -12,6 +12,7 @@ interface RankingsPageProps {
|
||||
metric?: string;
|
||||
local_authority?: string;
|
||||
year?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -25,9 +26,10 @@ export const metadata: Metadata = {
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||
const { metric: metricParam, local_authority, year: yearParam } = await searchParams;
|
||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||
|
||||
const metric = metricParam || 'rwm_expected_pct';
|
||||
const phase = phaseParam || 'primary';
|
||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||
|
||||
// Fetch rankings data with error handling
|
||||
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
local_authority,
|
||||
year,
|
||||
limit: 100,
|
||||
phase,
|
||||
}),
|
||||
fetchFilters(),
|
||||
fetchMetrics(),
|
||||
@@ -54,6 +57,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -68,6 +72,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
||||
selectedMetric={metric}
|
||||
selectedArea={local_authority}
|
||||
selectedYear={year}
|
||||
selectedPhase={phase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,47 @@
|
||||
}
|
||||
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.phaseTab {
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
/* Metric Selector */
|
||||
.metricSelector {
|
||||
background: var(--bg-card, white);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ComparisonView Component
|
||||
* Client-side comparison interface with charts and tables
|
||||
* Client-side comparison interface with phase tabs, charts, and tables
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
|
||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||
import type { ComparisonData, MetricDefinition } from '@/lib/types';
|
||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
||||
import { fetchComparison } from '@/lib/api';
|
||||
import styles from './ComparisonView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface ComparisonViewProps {
|
||||
initialData: Record<string, ComparisonData> | null;
|
||||
initialUrns: number[];
|
||||
@@ -39,6 +58,7 @@ export function ComparisonView({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [comparisonData, setComparisonData] = useState(initialData);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||
|
||||
// Seed context from initialData when component mounts and localStorage is empty
|
||||
useEffect(() => {
|
||||
@@ -84,6 +104,37 @@ export function ComparisonView({
|
||||
}
|
||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
||||
|
||||
// Classify schools by phase using comparison data
|
||||
const classifySchool = (school: School): 'primary' | 'secondary' => {
|
||||
const info = comparisonData?.[school.urn]?.school_info;
|
||||
if (info?.attainment_8_score != null) return 'secondary';
|
||||
if (info?.rwm_expected_pct != null) return 'primary';
|
||||
// Fallback: check yearly data
|
||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
||||
return 'primary';
|
||||
};
|
||||
|
||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||
|
||||
// Auto-select tab with more schools
|
||||
useEffect(() => {
|
||||
if (comparisonData && selectedSchools.length > 0) {
|
||||
if (secondarySchools.length > primarySchools.length) {
|
||||
setComparePhase('secondary');
|
||||
} else {
|
||||
setComparePhase('primary');
|
||||
}
|
||||
}
|
||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||
setComparePhase(phase);
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
setSelectedMetric(defaultMetric);
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
setSelectedMetric(metric);
|
||||
};
|
||||
@@ -100,6 +151,12 @@ export function ComparisonView({
|
||||
} catch { /* fallback: do nothing */ }
|
||||
};
|
||||
|
||||
const isPrimary = comparePhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||
|
||||
// Get metric definition
|
||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
||||
@@ -129,10 +186,20 @@ export function ComparisonView({
|
||||
);
|
||||
}
|
||||
|
||||
// Build filtered comparison data for active phase
|
||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||
if (comparisonData) {
|
||||
activeSchools.forEach(s => {
|
||||
if (comparisonData[s.urn]) {
|
||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get years for table
|
||||
const years =
|
||||
comparisonData && Object.keys(comparisonData).length > 0
|
||||
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
Object.keys(activeComparisonData).length > 0
|
||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -158,6 +225,33 @@ export function ComparisonView({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary ({primarySchools.length})
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary ({secondarySchools.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSchools.length === 0 ? (
|
||||
<EmptyState
|
||||
title={`No ${comparePhase} schools in your comparison`}
|
||||
message={`Add ${comparePhase} schools from search results to compare them here.`}
|
||||
action={{
|
||||
label: '+ Add Schools',
|
||||
onClick: () => setIsModalOpen(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Metric Selector */}
|
||||
<section className={styles.metricSelector}>
|
||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||
@@ -169,46 +263,17 @@ export function ComparisonView({
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.metricSelect}
|
||||
>
|
||||
<optgroup label="Expected Standard">
|
||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
{metrics.filter(m => m.category === 'average').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
{metrics.filter(m => m.category === 'context').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
{metrics.filter(m => m.category === '3yr').map((metric) => (
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
@@ -218,14 +283,14 @@ export function ComparisonView({
|
||||
{/* Progress score explanation */}
|
||||
{selectedMetric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* School Cards */}
|
||||
<section className={styles.schoolsSection}>
|
||||
<div className={styles.schoolsGrid}>
|
||||
{selectedSchools.map((school, index) => (
|
||||
{activeSchools.map((school, index) => (
|
||||
<div
|
||||
key={school.urn}
|
||||
className={styles.schoolCard}
|
||||
@@ -252,7 +317,7 @@ export function ComparisonView({
|
||||
</div>
|
||||
|
||||
{/* Latest metric value */}
|
||||
{comparisonData && comparisonData[school.urn] && (
|
||||
{activeComparisonData[school.urn] && (
|
||||
<div className={styles.latestValue}>
|
||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
||||
@@ -268,7 +333,7 @@ export function ComparisonView({
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const yearlyData = comparisonData[school.urn].yearly_data;
|
||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||
if (yearlyData.length === 0) return '-';
|
||||
|
||||
const latestData = yearlyData[yearlyData.length - 1];
|
||||
@@ -276,7 +341,6 @@ export function ComparisonView({
|
||||
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
// Format based on metric type
|
||||
if (selectedMetric.includes('progress')) {
|
||||
return formatProgress(value as number);
|
||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||
@@ -294,25 +358,25 @@ export function ComparisonView({
|
||||
</section>
|
||||
|
||||
{/* Comparison Chart */}
|
||||
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
||||
{Object.keys(activeComparisonData).length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||
<div className={styles.chartContainer}>
|
||||
<ComparisonChart
|
||||
comparisonData={comparisonData}
|
||||
comparisonData={activeComparisonData}
|
||||
metric={selectedMetric}
|
||||
metricLabel={metricLabel}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : selectedSchools.length > 0 ? (
|
||||
) : activeSchools.length > 0 ? (
|
||||
<section className={styles.chartSection}>
|
||||
<LoadingSkeleton type="list" />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Comparison Table */}
|
||||
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
|
||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||
<section className={styles.tableSection}>
|
||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||
<div className={styles.tableWrapper}>
|
||||
@@ -320,7 +384,7 @@ export function ComparisonView({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{selectedSchools.map((school) => (
|
||||
{activeSchools.map((school) => (
|
||||
<th key={school.urn}>{school.school_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -329,8 +393,8 @@ export function ComparisonView({
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td className={styles.yearCell}>{year}</td>
|
||||
{selectedSchools.map((school) => {
|
||||
const schoolData = comparisonData[school.urn];
|
||||
{activeSchools.map((school) => {
|
||||
const schoolData = activeComparisonData[school.urn];
|
||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||
|
||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||
@@ -342,7 +406,6 @@ export function ComparisonView({
|
||||
return <td key={school.urn}>-</td>;
|
||||
}
|
||||
|
||||
// Format based on metric type
|
||||
let displayValue: string;
|
||||
if (selectedMetric.includes('progress')) {
|
||||
displayValue = formatProgress(value as number);
|
||||
@@ -361,6 +424,8 @@ export function ComparisonView({
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* School Search Modal */}
|
||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
|
||||
@@ -32,8 +32,12 @@
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
@@ -154,3 +158,45 @@
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
.advancedSection {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
color: var(--text-secondary, #5a554d);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronUp {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
border-top: 5px solid currentColor;
|
||||
}
|
||||
|
||||
.chevronUp {
|
||||
border-bottom: 5px solid currentColor;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { isValidPostcode } from '@/lib/utils';
|
||||
import type { Filters } from '@/lib/types';
|
||||
import type { Filters, ResultFilters } from '@/lib/types';
|
||||
import styles from './FilterBar.module.css';
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: Filters;
|
||||
isHero?: boolean;
|
||||
resultFilters?: ResultFilters;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -32,9 +33,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
|
||||
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
|
||||
|
||||
// Count active dropdown filters (not search/postcode)
|
||||
const activeDropdownFilters = [currentLA, currentType, currentPhase, currentGender, currentAdmissionsPolicy, currentHasSixthForm].filter(Boolean);
|
||||
const hasActiveDropdownFilters = activeDropdownFilters.length > 0;
|
||||
const [filtersOpen, setFiltersOpen] = useState(hasActiveDropdownFilters);
|
||||
|
||||
// Auto-open if filters become active (e.g. URL change)
|
||||
useEffect(() => {
|
||||
if (hasActiveDropdownFilters) setFiltersOpen(true);
|
||||
}, [hasActiveDropdownFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Focus search on '/' or Ctrl+K, but not when typing in an input
|
||||
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
|
||||
document.activeElement?.tagName !== 'INPUT' &&
|
||||
document.activeElement?.tagName !== 'TEXTAREA' &&
|
||||
@@ -91,7 +101,15 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
};
|
||||
|
||||
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm;
|
||||
const isSecondaryMode = currentPhase === 'secondary' || (filters.genders && filters.genders.length > 0);
|
||||
|
||||
// Use result-scoped filter values when available, fall back to global
|
||||
const laOptions = resultFilters?.local_authorities ?? filters.local_authorities;
|
||||
const typeOptions = resultFilters?.school_types ?? filters.school_types;
|
||||
const phaseOptions = resultFilters?.phases ?? filters.phases ?? [];
|
||||
const genderOptions = resultFilters?.genders ?? filters.genders ?? [];
|
||||
const admissionsPolicyOptions = resultFilters?.admissions_policies ?? filters.admissions_policies ?? [];
|
||||
|
||||
const isSecondaryMode = currentPhase === 'secondary' || genderOptions.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
||||
@@ -128,6 +146,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
)}
|
||||
</form>
|
||||
|
||||
{!isHero && (
|
||||
<div className={styles.advancedSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.advancedToggle}
|
||||
onClick={() => setFiltersOpen(v => !v)}
|
||||
>
|
||||
Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
|
||||
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
|
||||
</button>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
@@ -136,10 +166,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{filters.local_authorities.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>{la}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -150,14 +178,12 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{filters.school_types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filters.phases && filters.phases.length > 0 && (
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange('phase', e.target.value)}
|
||||
@@ -165,17 +191,15 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{filters.phases.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{filters.genders && filters.genders.length > 0 && (
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) => handleFilterChange('gender', e.target.value)}
|
||||
@@ -183,7 +207,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{filters.genders.map((g) => (
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>{g}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -196,11 +220,11 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form (11–18)</option>
|
||||
<option value="no">Without sixth form (11–16)</option>
|
||||
<option value="yes">With sixth form (11-18)</option>
|
||||
<option value="no">Without sixth form (11-16)</option>
|
||||
</select>
|
||||
|
||||
{filters.admissions_policies && filters.admissions_policies.length > 0 && (
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
|
||||
@@ -208,7 +232,7 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{filters.admissions_policies.map((p) => (
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -222,6 +246,9 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
/>
|
||||
|
||||
{/* Discovery section shown on landing page before any search */}
|
||||
@@ -257,7 +258,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
||||
<>
|
||||
<div className={styles.schoolList}>
|
||||
{sortedSchools.map((school) => (
|
||||
isSecondaryView || school.attainment_8_score != null ? (
|
||||
school.attainment_8_score != null ? (
|
||||
<SecondarySchoolRow
|
||||
key={school.urn}
|
||||
school={school}
|
||||
|
||||
@@ -22,6 +22,47 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Phase Tabs */
|
||||
.phaseTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.phaseTab {
|
||||
padding: 0.625rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, white);
|
||||
color: var(--text-secondary, #5c564d);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.phaseTab:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.phaseTab:hover {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
.phaseTabActive {
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phaseTabActive:hover {
|
||||
background: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
background: var(--bg-card, white);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* RankingsView Component
|
||||
* Client-side rankings interface with filters
|
||||
* Client-side rankings interface with phase tabs and filters
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -12,6 +12,25 @@ import { formatPercentage, formatProgress } from '@/lib/utils';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import styles from './RankingsView.module.css';
|
||||
|
||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||
const SECONDARY_CATEGORIES = ['gcse'];
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
interface RankingsViewProps {
|
||||
rankings: RankingEntry[];
|
||||
filters: Filters;
|
||||
@@ -19,6 +38,7 @@ interface RankingsViewProps {
|
||||
selectedMetric: string;
|
||||
selectedArea?: string;
|
||||
selectedYear?: number;
|
||||
selectedPhase?: string;
|
||||
}
|
||||
|
||||
export function RankingsView({
|
||||
@@ -28,12 +48,17 @@ export function RankingsView({
|
||||
selectedMetric,
|
||||
selectedArea,
|
||||
selectedYear,
|
||||
selectedPhase = 'primary',
|
||||
}: RankingsViewProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { addSchool, isSelected } = useComparison();
|
||||
|
||||
const isPrimary = selectedPhase === 'primary';
|
||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
|
||||
const updateFilters = (updates: Record<string, string | undefined>) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -48,6 +73,11 @@ export function RankingsView({
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePhaseChange = (phase: string) => {
|
||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||
updateFilters({ phase, metric: defaultMetric });
|
||||
};
|
||||
|
||||
const handleMetricChange = (metric: string) => {
|
||||
updateFilters({ metric });
|
||||
};
|
||||
@@ -63,7 +93,6 @@ export function RankingsView({
|
||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||
addSchool({
|
||||
...ranking,
|
||||
// Ensure required School fields are present
|
||||
address: null,
|
||||
postcode: null,
|
||||
latitude: null,
|
||||
@@ -77,6 +106,9 @@ export function RankingsView({
|
||||
const isProgressScore = selectedMetric.includes('progress');
|
||||
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
||||
|
||||
// Filter metrics to only show relevant categories
|
||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header */}
|
||||
@@ -84,10 +116,26 @@ export function RankingsView({
|
||||
<h1>School Rankings</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Top-performing schools by {metricLabel.toLowerCase()}
|
||||
{!selectedArea && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||
{!selectedArea && rankings.length > 0 && <span className={styles.limitNote}> — showing top {rankings.length}</span>}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Phase Tabs */}
|
||||
<div className={styles.phaseTabs}>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('primary')}
|
||||
>
|
||||
Primary (KS2)
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||
onClick={() => handlePhaseChange('secondary')}
|
||||
>
|
||||
Secondary (GCSE)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentMetricDef?.description && (
|
||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||
)}
|
||||
@@ -107,46 +155,17 @@ export function RankingsView({
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
>
|
||||
<optgroup label="Expected Standard">
|
||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Higher Standard">
|
||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Progress Scores">
|
||||
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Average Scores">
|
||||
{metrics.filter(m => m.category === 'average').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Gender Performance">
|
||||
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Equity (Disadvantaged)">
|
||||
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="School Context">
|
||||
{metrics.filter(m => m.category === 'context').map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="3-Year Trends">
|
||||
{metrics.filter(m => m.category === '3yr').map((metric) => (
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||
if (groupMetrics.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{groupMetrics.map((metric) => (
|
||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +218,7 @@ export function RankingsView({
|
||||
message="Try selecting a different metric, area, or year."
|
||||
action={{
|
||||
label: 'Clear filters',
|
||||
onClick: () => router.push(pathname),
|
||||
onClick: () => router.push(`${pathname}?phase=${selectedPhase}`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
animation: rowFadeIn 0.3s ease-out both;
|
||||
}
|
||||
@@ -32,10 +32,10 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Line 1: name + type */
|
||||
/* Line 1: name + ofsted */
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -59,15 +59,24 @@
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.schoolType {
|
||||
/* Line 2: context tags */
|
||||
.line2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Line 2: stats */
|
||||
.line2 {
|
||||
.line2 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
/* Line 3: stats */
|
||||
.line3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -107,17 +116,17 @@
|
||||
.trendDown { color: var(--accent-coral, #e07256); }
|
||||
.trendStable { color: var(--text-muted, #8a847a); }
|
||||
|
||||
/* Line 3: location */
|
||||
.line3 {
|
||||
/* Line 4: location */
|
||||
.line4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.line3 span:not(:last-child)::after {
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
@@ -162,6 +171,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ofstedDate {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||
@@ -171,7 +184,7 @@
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -183,7 +196,7 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
.line3 {
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* SchoolRow Component
|
||||
* Three-line row for school search results
|
||||
* Four-line row for primary school search results
|
||||
*
|
||||
* Line 1: School name · School type
|
||||
* Line 2: R,W&M % · Progress score · Pupil count
|
||||
* Line 3: Local authority · Distance
|
||||
* Line 1: School name · Ofsted badge
|
||||
* Line 2: School type · Age range · Denomination · Gender
|
||||
* Line 3: R,W&M % · Progress score · Pupil count
|
||||
* Line 4: Local authority · Distance
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
@@ -48,28 +49,43 @@ export function SchoolRow({
|
||||
}
|
||||
};
|
||||
|
||||
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||||
const showDenomination =
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
return (
|
||||
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||
{/* Left: three content lines */}
|
||||
{/* Left: four content lines */}
|
||||
<div className={styles.rowContent}>
|
||||
|
||||
{/* Line 1: School name + type + Ofsted badge */}
|
||||
{/* Line 1: School name + Ofsted badge */}
|
||||
<div className={styles.line1}>
|
||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.school_type && (
|
||||
<span className={styles.schoolType}>{school.school_type}</span>
|
||||
)}
|
||||
{school.ofsted_grade && (
|
||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||
{OFSTED_LABELS[school.ofsted_grade]}
|
||||
{school.ofsted_date && (
|
||||
<span className={styles.ofstedDate}>
|
||||
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 2: Key stats */}
|
||||
{/* Line 2: Context tags */}
|
||||
<div className={styles.line2}>
|
||||
{school.school_type && <span>{school.school_type}</span>}
|
||||
{school.age_range && <span>{school.age_range}</span>}
|
||||
{showDenomination && <span>{school.religious_denomination}</span>}
|
||||
{showGender && <span>{school.gender}</span>}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Key stats */}
|
||||
<div className={styles.line3}>
|
||||
{school.rwm_expected_pct != null ? (
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
@@ -123,8 +139,8 @@ export function SchoolRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Location + distance */}
|
||||
<div className={styles.line3}>
|
||||
{/* Line 4: Location + distance */}
|
||||
<div className={styles.line4}>
|
||||
{school.local_authority && (
|
||||
<span>{school.local_authority}</span>
|
||||
)}
|
||||
@@ -133,11 +149,6 @@ export function SchoolRow({
|
||||
{school.distance.toFixed(1)} mi
|
||||
</span>
|
||||
)}
|
||||
{!isLocationSearch &&
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply' && (
|
||||
<span>{school.religious_denomination}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
border: 1px solid var(--border-color, #e5dfd5);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
animation: rowFadeIn 0.3s ease-out both;
|
||||
}
|
||||
@@ -32,10 +32,10 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Line 1: name + type + ofsted */
|
||||
/* Line 1: name + ofsted */
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -59,15 +59,24 @@
|
||||
color: var(--accent-coral, #e07256);
|
||||
}
|
||||
|
||||
.schoolType {
|
||||
/* Line 2: context tags */
|
||||
.line2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Line 2: KS4 stats */
|
||||
.line2 {
|
||||
.line2 > span:not(.provisionTag):not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
/* Line 3: KS4 stats */
|
||||
.line3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -109,17 +118,17 @@
|
||||
.deltaPositive { color: #3c8c3c; }
|
||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||
|
||||
/* Line 3: location + tags */
|
||||
.line3 {
|
||||
/* Line 4: location + distance */
|
||||
.line4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0;
|
||||
gap: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
}
|
||||
|
||||
.line3 span:not(:last-child)::after {
|
||||
.line4 span:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.4rem;
|
||||
color: var(--border-color, #e5dfd5);
|
||||
@@ -144,6 +153,7 @@
|
||||
color: var(--text-secondary, #5c5650);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
|
||||
.selectiveTag {
|
||||
@@ -191,7 +201,7 @@
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -203,7 +213,7 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.line2 {
|
||||
.line3 {
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* SecondarySchoolRow Component
|
||||
* Three-line row for secondary school search results
|
||||
* Four-line row for secondary school search results
|
||||
*
|
||||
* Line 1: School name · School type · Ofsted badge
|
||||
* Line 2: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
||||
* Line 3: LA name · gender tag · sixth form tag · admissions tag · distance
|
||||
* Line 1: School name · Ofsted badge
|
||||
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag
|
||||
* Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
||||
* Line 4: LA name · distance
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -67,17 +68,14 @@ export function SecondarySchoolRow({
|
||||
|
||||
return (
|
||||
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||
{/* Left: three content lines */}
|
||||
{/* Left: four content lines */}
|
||||
<div className={styles.rowContent}>
|
||||
|
||||
{/* Line 1: School name + type + Ofsted badge */}
|
||||
{/* Line 1: School name + Ofsted badge */}
|
||||
<div className={styles.line1}>
|
||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||
{school.school_name}
|
||||
</a>
|
||||
{school.school_type && (
|
||||
<span className={styles.schoolType}>{school.school_type}</span>
|
||||
)}
|
||||
{school.ofsted_grade && (
|
||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||
{OFSTED_LABELS[school.ofsted_grade]}
|
||||
@@ -90,8 +88,25 @@ export function SecondarySchoolRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 2: KS4 stats */}
|
||||
{/* Line 2: Context tags */}
|
||||
<div className={styles.line2}>
|
||||
{school.school_type && <span>{school.school_type}</span>}
|
||||
{school.age_range && <span>{school.age_range}</span>}
|
||||
{showGender && (
|
||||
<span className={styles.provisionTag}>{school.gender}</span>
|
||||
)}
|
||||
{sixthForm && (
|
||||
<span className={styles.provisionTag}>Sixth form</span>
|
||||
)}
|
||||
{admissionsTag && (
|
||||
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
|
||||
{admissionsTag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: KS4 stats */}
|
||||
<div className={styles.line3}>
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValueLarge}>
|
||||
{att8 != null ? att8.toFixed(1) : '—'}
|
||||
@@ -124,22 +139,11 @@ export function SecondarySchoolRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 3: Location + tags */}
|
||||
<div className={styles.line3}>
|
||||
{/* Line 4: Location + distance */}
|
||||
<div className={styles.line4}>
|
||||
{school.local_authority && (
|
||||
<span>{school.local_authority}</span>
|
||||
)}
|
||||
{showGender && (
|
||||
<span className={styles.provisionTag}>{school.gender}</span>
|
||||
)}
|
||||
{sixthForm && (
|
||||
<span className={styles.provisionTag}>Sixth form</span>
|
||||
)}
|
||||
{admissionsTag && (
|
||||
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
|
||||
{admissionsTag}
|
||||
</span>
|
||||
)}
|
||||
{isLocationSearch && school.distance != null && (
|
||||
<span className={styles.distanceBadge}>
|
||||
{school.distance.toFixed(1)} mi
|
||||
|
||||
@@ -273,6 +273,14 @@ export interface PaginationInfo {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface ResultFilters {
|
||||
local_authorities: string[];
|
||||
school_types: string[];
|
||||
phases: string[];
|
||||
genders: string[];
|
||||
admissions_policies: string[];
|
||||
}
|
||||
|
||||
export interface SchoolsResponse {
|
||||
schools: School[];
|
||||
page: number;
|
||||
@@ -280,6 +288,7 @@ export interface SchoolsResponse {
|
||||
total: number;
|
||||
total_pages: number;
|
||||
search_mode?: 'name' | 'location';
|
||||
result_filters?: ResultFilters;
|
||||
location_info?: {
|
||||
postcode: string;
|
||||
radius: number;
|
||||
@@ -399,6 +408,7 @@ export interface RankingsParams {
|
||||
year?: number;
|
||||
local_authority?: string;
|
||||
limit?: number;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
export interface ComparisonParams {
|
||||
|
||||
Reference in New Issue
Block a user