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 .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||||
from .utils import clean_for_json
|
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
|
# SECURITY MIDDLEWARE & HELPERS
|
||||||
@@ -380,6 +391,15 @@ async def get_schools(
|
|||||||
schools_df["school_type"].str.lower() == school_type.lower()
|
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
|
# Pagination
|
||||||
total = len(schools_df)
|
total = len(schools_df)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -392,6 +412,7 @@ async def get_schools(
|
|||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
|
||||||
|
"result_filters": result_filters,
|
||||||
"location_info": {
|
"location_info": {
|
||||||
"postcode": postcode,
|
"postcode": postcode,
|
||||||
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
"radius": radius * 1.60934, # Convert miles to km for frontend display
|
||||||
@@ -507,7 +528,11 @@ async def compare_schools(
|
|||||||
"urn": urn,
|
"urn": urn,
|
||||||
"school_name": latest.get("school_name", ""),
|
"school_name": latest.get("school_name", ""),
|
||||||
"local_authority": latest.get("local_authority", ""),
|
"local_authority": latest.get("local_authority", ""),
|
||||||
|
"school_type": latest.get("school_type", ""),
|
||||||
"address": latest.get("address", ""),
|
"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),
|
"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: 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]
|
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 []
|
genders = clean_filter_values(secondary_df["gender"]) 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 []
|
admissions_policies = clean_filter_values(secondary_df["admissions_policy"]) if "admissions_policy" in secondary_df.columns else []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
"local_authorities": clean_filter_values(df["local_authority"]) if "local_authority" in df.columns else [],
|
||||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
"school_types": clean_filter_values(df["school_type"]) if "school_type" in df.columns else [],
|
||||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||||
"phases": phases,
|
"phases": phases,
|
||||||
"genders": genders,
|
"genders": genders,
|
||||||
@@ -641,6 +666,9 @@ async def get_rankings(
|
|||||||
local_authority: Optional[str] = Query(
|
local_authority: Optional[str] = Query(
|
||||||
None, description="Filter by local authority", max_length=100
|
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."""
|
"""Get school rankings by a specific metric."""
|
||||||
# Sanitize local authority input
|
# Sanitize local authority input
|
||||||
@@ -670,6 +698,12 @@ async def get_rankings(
|
|||||||
if local_authority:
|
if local_authority:
|
||||||
df = df[df["local_authority"].str.lower() == local_authority.lower()]
|
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)
|
# Sort and rank (exclude rows with no data for this metric)
|
||||||
df = df.dropna(subset=[metric])
|
df = df.dropna(subset=[metric])
|
||||||
total = len(df)
|
total = len(df)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface RankingsPageProps {
|
|||||||
metric?: string;
|
metric?: string;
|
||||||
local_authority?: string;
|
local_authority?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
|
phase?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +26,10 @@ export const metadata: Metadata = {
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
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;
|
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||||
|
|
||||||
// Fetch rankings data with error handling
|
// Fetch rankings data with error handling
|
||||||
@@ -38,6 +40,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
local_authority,
|
local_authority,
|
||||||
year,
|
year,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
phase,
|
||||||
}),
|
}),
|
||||||
fetchFilters(),
|
fetchFilters(),
|
||||||
fetchMetrics(),
|
fetchMetrics(),
|
||||||
@@ -54,6 +57,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
selectedYear={year}
|
selectedYear={year}
|
||||||
|
selectedPhase={phase}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,6 +72,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
selectedYear={year}
|
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 */
|
/* Metric Selector */
|
||||||
.metricSelector {
|
.metricSelector {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonView Component
|
* ComparisonView Component
|
||||||
* Client-side comparison interface with charts and tables
|
* Client-side comparison interface with phase tabs, charts, and tables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -12,11 +12,30 @@ import { ComparisonChart } from './ComparisonChart';
|
|||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
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 { formatPercentage, formatProgress, CHART_COLORS } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import styles from './ComparisonView.module.css';
|
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 {
|
interface ComparisonViewProps {
|
||||||
initialData: Record<string, ComparisonData> | null;
|
initialData: Record<string, ComparisonData> | null;
|
||||||
initialUrns: number[];
|
initialUrns: number[];
|
||||||
@@ -39,6 +58,7 @@ export function ComparisonView({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
const [comparisonData, setComparisonData] = useState(initialData);
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
|
|
||||||
// Seed context from initialData when component mounts and localStorage is empty
|
// Seed context from initialData when component mounts and localStorage is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,6 +104,37 @@ export function ComparisonView({
|
|||||||
}
|
}
|
||||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
}, [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) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
setSelectedMetric(metric);
|
setSelectedMetric(metric);
|
||||||
};
|
};
|
||||||
@@ -100,6 +151,12 @@ export function ComparisonView({
|
|||||||
} catch { /* fallback: do nothing */ }
|
} 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
|
// Get metric definition
|
||||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
||||||
const metricLabel = currentMetricDef?.label || 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
|
// Get years for table
|
||||||
const years =
|
const years =
|
||||||
comparisonData && Object.keys(comparisonData).length > 0
|
Object.keys(activeComparisonData).length > 0
|
||||||
? comparisonData[Object.keys(comparisonData)[0]].yearly_data.map((d) => d.year)
|
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,208 +225,206 @@ export function ComparisonView({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Metric Selector */}
|
{/* Phase Tabs */}
|
||||||
<section className={styles.metricSelector}>
|
<div className={styles.phaseTabs}>
|
||||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
<button
|
||||||
Select Metric:
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
</label>
|
onClick={() => handlePhaseChange('primary')}
|
||||||
<select
|
|
||||||
id="metric-select"
|
|
||||||
value={selectedMetric}
|
|
||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
|
||||||
className={styles.metricSelect}
|
|
||||||
>
|
>
|
||||||
<optgroup label="Expected Standard">
|
Primary ({primarySchools.length})
|
||||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
</button>
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
<button
|
||||||
))}
|
className={`${styles.phaseTab} ${!isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
</optgroup>
|
onClick={() => handlePhaseChange('secondary')}
|
||||||
<optgroup label="Higher Standard">
|
>
|
||||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
Secondary ({secondarySchools.length})
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</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) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
{currentMetricDef?.description && (
|
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Progress score explanation */}
|
{activeSchools.length === 0 ? (
|
||||||
{selectedMetric.includes('progress') && (
|
<EmptyState
|
||||||
<p className={styles.progressNote}>
|
title={`No ${comparePhase} schools in your comparison`}
|
||||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
message={`Add ${comparePhase} schools from search results to compare them here.`}
|
||||||
</p>
|
action={{
|
||||||
)}
|
label: '+ Add Schools',
|
||||||
|
onClick: () => setIsModalOpen(true),
|
||||||
{/* School Cards */}
|
}}
|
||||||
<section className={styles.schoolsSection}>
|
/>
|
||||||
<div className={styles.schoolsGrid}>
|
) : (
|
||||||
{selectedSchools.map((school, index) => (
|
<>
|
||||||
<div
|
{/* Metric Selector */}
|
||||||
key={school.urn}
|
<section className={styles.metricSelector}>
|
||||||
className={styles.schoolCard}
|
<label htmlFor="metric-select" className={styles.metricLabel}>
|
||||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
Select Metric:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="metric-select"
|
||||||
|
value={selectedMetric}
|
||||||
|
onChange={(e) => handleMetricChange(e.target.value)}
|
||||||
|
className={styles.metricSelect}
|
||||||
>
|
>
|
||||||
<button
|
{optgroups.map(({ label, category }) => {
|
||||||
onClick={() => handleRemoveSchool(school.urn)}
|
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||||
className={styles.removeButton}
|
if (groupMetrics.length === 0) return null;
|
||||||
aria-label={`Remove ${school.school_name}`}
|
return (
|
||||||
title="Remove from comparison"
|
<optgroup key={category} label={label}>
|
||||||
>
|
{groupMetrics.map((metric) => (
|
||||||
×
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||||
</button>
|
))}
|
||||||
<h2 className={styles.schoolName}>
|
</optgroup>
|
||||||
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
);
|
||||||
</h2>
|
})}
|
||||||
<div className={styles.schoolMeta}>
|
</select>
|
||||||
{school.local_authority && (
|
{currentMetricDef?.description && (
|
||||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
{school.school_type && (
|
</section>
|
||||||
<span className={styles.metaItem}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest metric value */}
|
{/* Progress score explanation */}
|
||||||
{comparisonData && comparisonData[school.urn] && (
|
{selectedMetric.includes('progress') && (
|
||||||
<div className={styles.latestValue}>
|
<p className={styles.progressNote}>
|
||||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
||||||
<div className={styles.latestNumber} style={{ color: CHART_COLORS[index % CHART_COLORS.length] }}>
|
</p>
|
||||||
<span
|
)}
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const yearlyData = comparisonData[school.urn].yearly_data;
|
|
||||||
if (yearlyData.length === 0) return '-';
|
|
||||||
|
|
||||||
const latestData = yearlyData[yearlyData.length - 1];
|
{/* School Cards */}
|
||||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
<section className={styles.schoolsSection}>
|
||||||
|
<div className={styles.schoolsGrid}>
|
||||||
if (value === null || value === undefined) return '-';
|
{activeSchools.map((school, index) => (
|
||||||
|
<div
|
||||||
// Format based on metric type
|
key={school.urn}
|
||||||
if (selectedMetric.includes('progress')) {
|
className={styles.schoolCard}
|
||||||
return formatProgress(value as number);
|
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
>
|
||||||
return formatPercentage(value as number);
|
<button
|
||||||
} else {
|
onClick={() => handleRemoveSchool(school.urn)}
|
||||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
className={styles.removeButton}
|
||||||
}
|
aria-label={`Remove ${school.school_name}`}
|
||||||
})()}
|
title="Remove from comparison"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 className={styles.schoolName}>
|
||||||
|
<a href={`/school/${school.urn}`}>{school.school_name}</a>
|
||||||
|
</h2>
|
||||||
|
<div className={styles.schoolMeta}>
|
||||||
|
{school.local_authority && (
|
||||||
|
<span className={styles.metaItem}>{school.local_authority}</span>
|
||||||
|
)}
|
||||||
|
{school.school_type && (
|
||||||
|
<span className={styles.metaItem}>{school.school_type}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Latest metric value */}
|
||||||
|
{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] }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: CHART_COLORS[index % CHART_COLORS.length],
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
||||||
|
if (yearlyData.length === 0) return '-';
|
||||||
|
|
||||||
|
const latestData = yearlyData[yearlyData.length - 1];
|
||||||
|
const value = latestData[selectedMetric as keyof typeof latestData];
|
||||||
|
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
|
||||||
|
if (selectedMetric.includes('progress')) {
|
||||||
|
return formatProgress(value as number);
|
||||||
|
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||||
|
return formatPercentage(value as number);
|
||||||
|
} else {
|
||||||
|
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Comparison Chart */}
|
{/* Comparison Chart */}
|
||||||
{comparisonData && Object.keys(comparisonData).length > 0 ? (
|
{Object.keys(activeComparisonData).length > 0 ? (
|
||||||
<section className={styles.chartSection}>
|
<section className={styles.chartSection}>
|
||||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<ComparisonChart
|
<ComparisonChart
|
||||||
comparisonData={comparisonData}
|
comparisonData={activeComparisonData}
|
||||||
metric={selectedMetric}
|
metric={selectedMetric}
|
||||||
metricLabel={metricLabel}
|
metricLabel={metricLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : selectedSchools.length > 0 ? (
|
) : activeSchools.length > 0 ? (
|
||||||
<section className={styles.chartSection}>
|
<section className={styles.chartSection}>
|
||||||
<LoadingSkeleton type="list" />
|
<LoadingSkeleton type="list" />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Comparison Table */}
|
{/* Comparison Table */}
|
||||||
{comparisonData && Object.keys(comparisonData).length > 0 && years.length > 0 && (
|
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
||||||
<section className={styles.tableSection}>
|
<section className={styles.tableSection}>
|
||||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<table className={styles.comparisonTable}>
|
<table className={styles.comparisonTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Year</th>
|
<th>Year</th>
|
||||||
{selectedSchools.map((school) => (
|
{activeSchools.map((school) => (
|
||||||
<th key={school.urn}>{school.school_name}</th>
|
<th key={school.urn}>{school.school_name}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{years.map((year) => (
|
{years.map((year) => (
|
||||||
<tr key={year}>
|
<tr key={year}>
|
||||||
<td className={styles.yearCell}>{year}</td>
|
<td className={styles.yearCell}>{year}</td>
|
||||||
{selectedSchools.map((school) => {
|
{activeSchools.map((school) => {
|
||||||
const schoolData = comparisonData[school.urn];
|
const schoolData = activeComparisonData[school.urn];
|
||||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
if (!schoolData) return <td key={school.urn}>-</td>;
|
||||||
|
|
||||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
||||||
if (!yearData) return <td key={school.urn}>-</td>;
|
if (!yearData) return <td key={school.urn}>-</td>;
|
||||||
|
|
||||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
const value = yearData[selectedMetric as keyof typeof yearData];
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return <td key={school.urn}>-</td>;
|
return <td key={school.urn}>-</td>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format based on metric type
|
let displayValue: string;
|
||||||
let displayValue: string;
|
if (selectedMetric.includes('progress')) {
|
||||||
if (selectedMetric.includes('progress')) {
|
displayValue = formatProgress(value as number);
|
||||||
displayValue = formatProgress(value as number);
|
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
displayValue = formatPercentage(value as number);
|
||||||
displayValue = formatPercentage(value as number);
|
} else {
|
||||||
} else {
|
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
||||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return <td key={school.urn}>{displayValue}</td>;
|
return <td key={school.urn}>{displayValue}</td>;
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* School Search Modal */}
|
{/* School Search Modal */}
|
||||||
|
|||||||
@@ -32,8 +32,12 @@
|
|||||||
padding: 1.25rem 2.5rem;
|
padding: 1.25rem 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroMode .searchSection {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omniBoxContainer {
|
.omniBoxContainer {
|
||||||
@@ -154,3 +158,45 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
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 { useState, useCallback, useTransition, useRef, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
import { isValidPostcode } from '@/lib/utils';
|
import { isValidPostcode } from '@/lib/utils';
|
||||||
import type { Filters } from '@/lib/types';
|
import type { Filters, ResultFilters } from '@/lib/types';
|
||||||
import styles from './FilterBar.module.css';
|
import styles from './FilterBar.module.css';
|
||||||
|
|
||||||
interface FilterBarProps {
|
interface FilterBarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
|
resultFilters?: ResultFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBar({ filters, isHero }: FilterBarProps) {
|
export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -32,9 +33,18 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
|
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
|
||||||
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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))) &&
|
if ((e.key === '/' || (e.key === 'k' && (e.ctrlKey || e.metaKey))) &&
|
||||||
document.activeElement?.tagName !== 'INPUT' &&
|
document.activeElement?.tagName !== 'INPUT' &&
|
||||||
document.activeElement?.tagName !== 'TEXTAREA' &&
|
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 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 (
|
return (
|
||||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
||||||
@@ -128,100 +146,109 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className={styles.filters}>
|
{!isHero && (
|
||||||
<select
|
<div className={styles.advancedSection}>
|
||||||
value={currentLA}
|
<button
|
||||||
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
|
type="button"
|
||||||
className={styles.filterSelect}
|
className={styles.advancedToggle}
|
||||||
disabled={isPending}
|
onClick={() => setFiltersOpen(v => !v)}
|
||||||
>
|
|
||||||
<option value="">All Local Authorities</option>
|
|
||||||
{filters.local_authorities.map((la) => (
|
|
||||||
<option key={la} value={la}>
|
|
||||||
{la}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={currentType}
|
|
||||||
onChange={(e) => handleFilterChange('school_type', e.target.value)}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="">All School Types</option>
|
|
||||||
{filters.school_types.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{type}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{filters.phases && filters.phases.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={currentPhase}
|
|
||||||
onChange={(e) => handleFilterChange('phase', e.target.value)}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
>
|
||||||
<option value="">All Phases</option>
|
Advanced filters{hasActiveDropdownFilters ? ` (${activeDropdownFilters.length})` : ''}
|
||||||
{filters.phases.map((p) => (
|
<span className={filtersOpen ? styles.chevronUp : styles.chevronDown} />
|
||||||
<option key={p} value={p.toLowerCase()}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSecondaryMode && (
|
|
||||||
<>
|
|
||||||
{filters.genders && filters.genders.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={currentGender}
|
|
||||||
onChange={(e) => handleFilterChange('gender', e.target.value)}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="">Boys, Girls & Mixed</option>
|
|
||||||
{filters.genders.map((g) => (
|
|
||||||
<option key={g} value={g.toLowerCase()}>{g}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={currentHasSixthForm}
|
|
||||||
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
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>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{filters.admissions_policies && filters.admissions_policies.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={currentAdmissionsPolicy}
|
|
||||||
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="">All admissions types</option>
|
|
||||||
{filters.admissions_policies.map((p) => (
|
|
||||||
<option key={p} value={p.toLowerCase()}>{p}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
{filtersOpen && (
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<select
|
||||||
|
value={currentLA}
|
||||||
|
onChange={(e) => handleFilterChange('local_authority', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All Local Authorities</option>
|
||||||
|
{laOptions.map((la) => (
|
||||||
|
<option key={la} value={la}>{la}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentType}
|
||||||
|
onChange={(e) => handleFilterChange('school_type', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All School Types</option>
|
||||||
|
{typeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{phaseOptions.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentPhase}
|
||||||
|
onChange={(e) => handleFilterChange('phase', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All Phases</option>
|
||||||
|
{phaseOptions.map((p) => (
|
||||||
|
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSecondaryMode && (
|
||||||
|
<>
|
||||||
|
{genderOptions.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentGender}
|
||||||
|
onChange={(e) => handleFilterChange('gender', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">Boys, Girls & Mixed</option>
|
||||||
|
{genderOptions.map((g) => (
|
||||||
|
<option key={g} value={g.toLowerCase()}>{g}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentHasSixthForm}
|
||||||
|
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
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>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{admissionsPolicyOptions.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentAdmissionsPolicy}
|
||||||
|
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All admissions types</option>
|
||||||
|
{admissionsPolicyOptions.map((p) => (
|
||||||
|
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
isHero={!isSearchActive}
|
isHero={!isSearchActive}
|
||||||
|
resultFilters={initialSchools.result_filters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Discovery section shown on landing page before any search */}
|
{/* Discovery section shown on landing page before any search */}
|
||||||
@@ -257,7 +258,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<>
|
<>
|
||||||
<div className={styles.schoolList}>
|
<div className={styles.schoolList}>
|
||||||
{sortedSchools.map((school) => (
|
{sortedSchools.map((school) => (
|
||||||
isSecondaryView || school.attainment_8_score != null ? (
|
school.attainment_8_score != null ? (
|
||||||
<SecondarySchoolRow
|
<SecondarySchoolRow
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
|
|||||||
@@ -22,6 +22,47 @@
|
|||||||
line-height: 1.6;
|
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 */
|
||||||
.filters {
|
.filters {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* RankingsView Component
|
* RankingsView Component
|
||||||
* Client-side rankings interface with filters
|
* Client-side rankings interface with phase tabs and filters
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -12,6 +12,25 @@ import { formatPercentage, formatProgress } from '@/lib/utils';
|
|||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import styles from './RankingsView.module.css';
|
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 {
|
interface RankingsViewProps {
|
||||||
rankings: RankingEntry[];
|
rankings: RankingEntry[];
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -19,6 +38,7 @@ interface RankingsViewProps {
|
|||||||
selectedMetric: string;
|
selectedMetric: string;
|
||||||
selectedArea?: string;
|
selectedArea?: string;
|
||||||
selectedYear?: number;
|
selectedYear?: number;
|
||||||
|
selectedPhase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingsView({
|
export function RankingsView({
|
||||||
@@ -28,12 +48,17 @@ export function RankingsView({
|
|||||||
selectedMetric,
|
selectedMetric,
|
||||||
selectedArea,
|
selectedArea,
|
||||||
selectedYear,
|
selectedYear,
|
||||||
|
selectedPhase = 'primary',
|
||||||
}: RankingsViewProps) {
|
}: RankingsViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { addSchool, isSelected } = useComparison();
|
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 updateFilters = (updates: Record<string, string | undefined>) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -48,6 +73,11 @@ export function RankingsView({
|
|||||||
router.push(`${pathname}?${params.toString()}`);
|
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) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
updateFilters({ metric });
|
updateFilters({ metric });
|
||||||
};
|
};
|
||||||
@@ -63,7 +93,6 @@ export function RankingsView({
|
|||||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||||
addSchool({
|
addSchool({
|
||||||
...ranking,
|
...ranking,
|
||||||
// Ensure required School fields are present
|
|
||||||
address: null,
|
address: null,
|
||||||
postcode: null,
|
postcode: null,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
@@ -77,6 +106,9 @@ export function RankingsView({
|
|||||||
const isProgressScore = selectedMetric.includes('progress');
|
const isProgressScore = selectedMetric.includes('progress');
|
||||||
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -84,10 +116,26 @@ export function RankingsView({
|
|||||||
<h1>School Rankings</h1>
|
<h1>School Rankings</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Top-performing schools by {metricLabel.toLowerCase()}
|
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>
|
</p>
|
||||||
</header>
|
</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 && (
|
{currentMetricDef?.description && (
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
||||||
)}
|
)}
|
||||||
@@ -107,46 +155,17 @@ export function RankingsView({
|
|||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
onChange={(e) => handleMetricChange(e.target.value)}
|
||||||
className={styles.filterSelect}
|
className={styles.filterSelect}
|
||||||
>
|
>
|
||||||
<optgroup label="Expected Standard">
|
{optgroups.map(({ label, category }) => {
|
||||||
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
if (groupMetrics.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</optgroup>
|
<optgroup key={category} label={label}>
|
||||||
<optgroup label="Higher Standard">
|
{groupMetrics.map((metric) => (
|
||||||
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
))}
|
||||||
))}
|
</optgroup>
|
||||||
</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) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,7 +218,7 @@ export function RankingsView({
|
|||||||
message="Try selecting a different metric, area, or year."
|
message="Try selecting a different metric, area, or year."
|
||||||
action={{
|
action={{
|
||||||
label: 'Clear filters',
|
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: 1px solid var(--border-color, #e5dfd5);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 1rem 1.25rem;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
animation: rowFadeIn 0.3s ease-out both;
|
animation: rowFadeIn 0.3s ease-out both;
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 1: name + type */
|
/* Line 1: name + ofsted */
|
||||||
.line1 {
|
.line1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -59,15 +59,24 @@
|
|||||||
color: var(--accent-coral, #e07256);
|
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;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 2: stats */
|
.line2 span:not(:last-child)::after {
|
||||||
.line2 {
|
content: '·';
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
color: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line 3: stats */
|
||||||
|
.line3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -107,17 +116,17 @@
|
|||||||
.trendDown { color: var(--accent-coral, #e07256); }
|
.trendDown { color: var(--accent-coral, #e07256); }
|
||||||
.trendStable { color: var(--text-muted, #8a847a); }
|
.trendStable { color: var(--text-muted, #8a847a); }
|
||||||
|
|
||||||
/* Line 3: location */
|
/* Line 4: location */
|
||||||
.line3 {
|
.line4 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0 0;
|
gap: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line3 span:not(:last-child)::after {
|
.line4 span:not(:last-child)::after {
|
||||||
content: '·';
|
content: '·';
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.4rem;
|
||||||
color: var(--border-color, #e5dfd5);
|
color: var(--border-color, #e5dfd5);
|
||||||
@@ -162,6 +171,10 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ofstedDate {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||||
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||||
@@ -171,7 +184,7 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.row {
|
.row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0.75rem;
|
padding: 0.875rem;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +196,7 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line2 {
|
.line3 {
|
||||||
gap: 0 1rem;
|
gap: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* SchoolRow Component
|
* SchoolRow Component
|
||||||
* Three-line row for school search results
|
* Four-line row for primary school search results
|
||||||
*
|
*
|
||||||
* Line 1: School name · School type
|
* Line 1: School name · Ofsted badge
|
||||||
* Line 2: R,W&M % · Progress score · Pupil count
|
* Line 2: School type · Age range · Denomination · Gender
|
||||||
* Line 3: Local authority · Distance
|
* Line 3: R,W&M % · Progress score · Pupil count
|
||||||
|
* Line 4: Local authority · Distance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School } from '@/lib/types';
|
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 (
|
return (
|
||||||
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||||
{/* Left: three content lines */}
|
{/* Left: four content lines */}
|
||||||
<div className={styles.rowContent}>
|
<div className={styles.rowContent}>
|
||||||
|
|
||||||
{/* Line 1: School name + type + Ofsted badge */}
|
{/* Line 1: School name + Ofsted badge */}
|
||||||
<div className={styles.line1}>
|
<div className={styles.line1}>
|
||||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.school_type && (
|
|
||||||
<span className={styles.schoolType}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
{school.ofsted_grade && (
|
{school.ofsted_grade && (
|
||||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||||
{OFSTED_LABELS[school.ofsted_grade]}
|
{OFSTED_LABELS[school.ofsted_grade]}
|
||||||
|
{school.ofsted_date && (
|
||||||
|
<span className={styles.ofstedDate}>
|
||||||
|
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 2: Key stats */}
|
{/* Line 2: Context tags */}
|
||||||
<div className={styles.line2}>
|
<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 ? (
|
{school.rwm_expected_pct != null ? (
|
||||||
<span className={styles.stat}>
|
<span className={styles.stat}>
|
||||||
<strong className={styles.statValue}>
|
<strong className={styles.statValue}>
|
||||||
@@ -123,8 +139,8 @@ export function SchoolRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 3: Location + distance */}
|
{/* Line 4: Location + distance */}
|
||||||
<div className={styles.line3}>
|
<div className={styles.line4}>
|
||||||
{school.local_authority && (
|
{school.local_authority && (
|
||||||
<span>{school.local_authority}</span>
|
<span>{school.local_authority}</span>
|
||||||
)}
|
)}
|
||||||
@@ -133,11 +149,6 @@ export function SchoolRow({
|
|||||||
{school.distance.toFixed(1)} mi
|
{school.distance.toFixed(1)} mi
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isLocationSearch &&
|
|
||||||
school.religious_denomination &&
|
|
||||||
school.religious_denomination !== 'Does not apply' && (
|
|
||||||
<span>{school.religious_denomination}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 1rem 1.25rem;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
animation: rowFadeIn 0.3s ease-out both;
|
animation: rowFadeIn 0.3s ease-out both;
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 1: name + type + ofsted */
|
/* Line 1: name + ofsted */
|
||||||
.line1 {
|
.line1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -59,15 +59,24 @@
|
|||||||
color: var(--accent-coral, #e07256);
|
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;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line 2: KS4 stats */
|
.line2 > span:not(.provisionTag):not(:last-child)::after {
|
||||||
.line2 {
|
content: '·';
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
color: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line 3: KS4 stats */
|
||||||
|
.line3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -109,17 +118,17 @@
|
|||||||
.deltaPositive { color: #3c8c3c; }
|
.deltaPositive { color: #3c8c3c; }
|
||||||
.deltaNegative { color: var(--accent-coral, #e07256); }
|
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
/* Line 3: location + tags */
|
/* Line 4: location + distance */
|
||||||
.line3 {
|
.line4 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0 0;
|
gap: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.line3 span:not(:last-child)::after {
|
.line4 span:not(:last-child)::after {
|
||||||
content: '·';
|
content: '·';
|
||||||
margin: 0 0.4rem;
|
margin: 0 0.4rem;
|
||||||
color: var(--border-color, #e5dfd5);
|
color: var(--border-color, #e5dfd5);
|
||||||
@@ -144,6 +153,7 @@
|
|||||||
color: var(--text-secondary, #5c5650);
|
color: var(--text-secondary, #5c5650);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
margin-left: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectiveTag {
|
.selectiveTag {
|
||||||
@@ -191,7 +201,7 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.row {
|
.row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0.75rem;
|
padding: 0.875rem;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +213,7 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line2 {
|
.line3 {
|
||||||
gap: 0 1rem;
|
gap: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* SecondarySchoolRow Component
|
* 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 1: School name · Ofsted badge
|
||||||
* Line 2: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag
|
||||||
* Line 3: LA name · gender tag · sixth form tag · admissions tag · distance
|
* Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
||||||
|
* Line 4: LA name · distance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -67,17 +68,14 @@ export function SecondarySchoolRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||||
{/* Left: three content lines */}
|
{/* Left: four content lines */}
|
||||||
<div className={styles.rowContent}>
|
<div className={styles.rowContent}>
|
||||||
|
|
||||||
{/* Line 1: School name + type + Ofsted badge */}
|
{/* Line 1: School name + Ofsted badge */}
|
||||||
<div className={styles.line1}>
|
<div className={styles.line1}>
|
||||||
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||||
{school.school_name}
|
{school.school_name}
|
||||||
</a>
|
</a>
|
||||||
{school.school_type && (
|
|
||||||
<span className={styles.schoolType}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
{school.ofsted_grade && (
|
{school.ofsted_grade && (
|
||||||
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||||
{OFSTED_LABELS[school.ofsted_grade]}
|
{OFSTED_LABELS[school.ofsted_grade]}
|
||||||
@@ -90,8 +88,25 @@ export function SecondarySchoolRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 2: KS4 stats */}
|
{/* Line 2: Context tags */}
|
||||||
<div className={styles.line2}>
|
<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}>
|
<span className={styles.stat}>
|
||||||
<strong className={styles.statValueLarge}>
|
<strong className={styles.statValueLarge}>
|
||||||
{att8 != null ? att8.toFixed(1) : '—'}
|
{att8 != null ? att8.toFixed(1) : '—'}
|
||||||
@@ -124,22 +139,11 @@ export function SecondarySchoolRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line 3: Location + tags */}
|
{/* Line 4: Location + distance */}
|
||||||
<div className={styles.line3}>
|
<div className={styles.line4}>
|
||||||
{school.local_authority && (
|
{school.local_authority && (
|
||||||
<span>{school.local_authority}</span>
|
<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 && (
|
{isLocationSearch && school.distance != null && (
|
||||||
<span className={styles.distanceBadge}>
|
<span className={styles.distanceBadge}>
|
||||||
{school.distance.toFixed(1)} mi
|
{school.distance.toFixed(1)} mi
|
||||||
|
|||||||
@@ -273,6 +273,14 @@ export interface PaginationInfo {
|
|||||||
total_pages: number;
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResultFilters {
|
||||||
|
local_authorities: string[];
|
||||||
|
school_types: string[];
|
||||||
|
phases: string[];
|
||||||
|
genders: string[];
|
||||||
|
admissions_policies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SchoolsResponse {
|
export interface SchoolsResponse {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
page: number;
|
page: number;
|
||||||
@@ -280,6 +288,7 @@ export interface SchoolsResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
total_pages: number;
|
total_pages: number;
|
||||||
search_mode?: 'name' | 'location';
|
search_mode?: 'name' | 'location';
|
||||||
|
result_filters?: ResultFilters;
|
||||||
location_info?: {
|
location_info?: {
|
||||||
postcode: string;
|
postcode: string;
|
||||||
radius: number;
|
radius: number;
|
||||||
@@ -399,6 +408,7 @@ export interface RankingsParams {
|
|||||||
year?: number;
|
year?: number;
|
||||||
local_authority?: string;
|
local_authority?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
phase?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparisonParams {
|
export interface ComparisonParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user