feat(school): state the header's facts in one line, and flag what applies

The row of grey chips under the name mixed the council, a GIAS governance
term, the age range and provision, with no labels. Parents could not tell
what "Wandsworth" or "Nursery" meant, and selective, faith and fees were
missing or buried in the details.

The header now prints one fact line (phase pill · ages · type · pupils),
then flags only for what applies, in a fixed order: who can apply or what
it costs (Fee-paying, Selective, Girls'/Boys' school, the religious
character as recorded), then what the school offers (Nursery class, Sixth
form). Flags carry no hue: they are facts, not verdicts. The council joins
the address unless the address already names it. The details keep
headteacher, website, phone, academy trust (hidden when it has the
school's own name) and capacity; county, constituency and religious
character leave the header. The proposed-to-close strip is unchanged.

lib/schoolFacts holds the type label and the flags so the search rows can
print the same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-10-02 22:59:45 +01:00
1 parent 62a6bfaf0c
commit ca47d08186
7 files changed
+352 -86

No files matched your search

@@ -145,20 +145,76 @@
}
.meta {
/* Fact line: the phase pill, then plain register values joined by dots. */
.facts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
row-gap: 0.25rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.metaItem {
font-size: 0.8125rem;
.fact + .fact::before {
content: "·";
margin: 0 0.5rem;
color: var(--text-muted);
}
/* The search rows' phase pill, in the same phase colours. */
.phasePill {
margin-right: 0.625rem;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
}
.phasePillPrimary { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.phasePillSecondary { background: var(--phase-secondary-bg); color: var(--phase-secondary-text); }
.phasePillAllThrough { background: var(--phase-all-through-bg); color: var(--phase-all-through-text); }
.phasePillPost16 { background: var(--phase-post16-bg); color: var(--phase-post16-text); }
.phasePillNursery { background: var(--phase-nursery-bg); color: var(--phase-nursery-text); }
/* Flags are facts, not verdicts, so they carry no hue. Outlined changes who
can apply or what it costs; filled is what the school offers. Text may
wrap: the longest religious character runs past a 360px line. */
.flags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0 0 0.625rem;
padding: 0;
list-style: none;
}
.flagCondition,
.flagProvision {
padding: 0.0625rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border: 1px solid transparent;
border-radius: 4px;
}
.flagCondition {
border-color: rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
.flagProvision {
background: rgba(var(--ink-rgb), 0.07);
color: var(--text-secondary);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary);
border-radius: 3px;
}
@@ -703,17 +759,8 @@
word-break: break-word;
}
/* Pills wrap horizontally instead of stacking — short tokens like
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
rows of empty horizontal space. */
.meta {
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
}
/* Secondary header info (headteacher, website, pupil count, trust,
contact, area) isn't needed above the fold on phones/tablets, so it's
/* Secondary header info (headteacher, website, phone, trust, capacity)
isn't needed above the fold on phones/tablets, so it's
collapsed by default and revealed on demand via the "Show all details"
link — reclaiming the vertical space so the metrics surface sooner. */
.detailsToggle {
@@ -20,8 +20,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
import { formatAgeRange, hasNurseryClasses, isProposedToClose, singleSexLabel } from '@/lib/utils';
import type { School, SchoolCensus } from '@/lib/types';
import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
@@ -41,6 +42,12 @@ export interface SchoolDetailShellProps {
children: ReactNode;
}
/** Equal ignoring case and punctuation: "TIFFIN SCHOOL" is Tiffin School. */
function sameName(a: string, b: string): boolean {
const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
return key(a) === key(b);
}
export function SchoolDetailShell({
schoolInfo, census, navItems, children,
}: SchoolDetailShellProps) {
@@ -61,7 +68,7 @@ export function SchoolDetailShell({
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
// "All ▾" jump menu listing every section.
const [sectionsOpen, setSectionsOpen] = useState(false);
// Header details (headteacher, contact, trust, area) collapse behind a
// Header details (headteacher, contact, trust, capacity) collapse behind a
// "Show all details" link on mobile/tablet, where they're below the fold.
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -125,9 +132,28 @@ export function SchoolDetailShell({
// composers; recomputing them here would duplicate that work for values
// this component never renders.
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const singleSex = singleSexLabel(schoolInfo.gender);
// Header facts: each is one register value in a fixed slot (lib/schoolFacts).
const phasePill = getPhaseStyle(schoolInfo.phase);
// Never latestResults.total_pupils: that is the results cohort, which for a
// secondary is the GCSE year group alone.
const pupils = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
const facts = [
formatAgeRange(schoolInfo.age_range),
schoolTypeLabel(schoolInfo),
pupils != null ? `${pupils.toLocaleString()} pupils` : null,
].filter((fact): fact is string => !!fact);
const flags = schoolFlags(schoolInfo);
// The council, unless the address already names it.
const council = schoolInfo.local_authority
&& !(schoolInfo.address ?? '').toLowerCase().includes(schoolInfo.local_authority.toLowerCase())
? schoolInfo.local_authority
: null;
// A single-academy trust carries the school's own name, which says nothing.
const trust = schoolInfo.trust_name && !sameName(schoolInfo.trust_name, schoolInfo.school_name)
? schoolInfo.trust_name
: null;
const handleComparisonToggle = () => {
if (isInComparison) {
@@ -202,27 +228,31 @@ export function SchoolDetailShell({
<div className={styles.headerContent}>
<div className={styles.titleSection}>
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
<div className={styles.meta}>
{schoolInfo.local_authority && (
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
)}
{schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
)}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{singleSex && <span className={styles.metaItem}>{singleSex}</span>}
{schoolInfo.age_range && (
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
)}
{hasNurseryClasses(schoolInfo.nursery_provision) && (
<span className={styles.metaItem}>Nursery</span>
)}
{schoolInfo.has_sixth_form && (
<span className={styles.metaItem}>Sixth form</span>
)}
</div>
{(phasePill.label || facts.length > 0) && (
<div className={styles.facts}>
{phasePill.label && (
<span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
{phasePill.label}
</span>
)}
{facts.map((fact) => (
<span key={fact} className={styles.fact}>{fact}</span>
))}
</div>
)}
{flags.length > 0 && (
// role="list": list-style: none drops list semantics in Safari.
<ul role="list" className={styles.flags} aria-label="Admission and provision">
{flags.map((flag) => (
<li
key={flag.label}
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
>
{flag.label}
</li>
))}
</ul>
)}
{isProposedToClose(schoolInfo) && (
<div className={styles.closingStrip} role="note">
<strong>⚠ Proposed to close.</strong> Check with the local authority before
@@ -232,6 +262,7 @@ export function SchoolDetailShell({
{schoolInfo.address && (
<p className={styles.address}>
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
{council && ` · ${council}`}
{hasLocation && (
<>
{' · '}
@@ -278,23 +309,6 @@ export function SchoolDetailShell({
</a>
</span>
)}
{(() => {
// Never latestResults.total_pupils: that is the results
// cohort, which for a secondary is the GCSE year group alone.
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? null;
if (total == null) return null;
return (
<span className={styles.headerDetail}>
<strong>Pupils:</strong> {total.toLocaleString()}
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
</span>
);
})()}
{schoolInfo.trust_name && (
<span className={styles.headerDetail}>
Part of <strong>{schoolInfo.trust_name}</strong>
</span>
)}
{schoolInfo.telephone && (
<span className={styles.headerDetail}>
<strong>Phone:</strong>{' '}
@@ -303,22 +317,14 @@ export function SchoolDetailShell({
</a>
</span>
)}
{schoolInfo.religious_denomination && (
{trust && (
<span className={styles.headerDetail}>
<strong>Religious character:</strong>{' '}
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
? 'None'
: schoolInfo.religious_denomination}
<strong>Academy trust:</strong> {trust}
</span>
)}
{schoolInfo.county && (
{schoolInfo.capacity != null && (
<span className={styles.headerDetail}>
<strong>County:</strong> {schoolInfo.county}
</span>
)}
{schoolInfo.parliamentary_constituency && (
<span className={styles.headerDetail}>
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
<strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
</span>
)}
</div>