feat(school): facts and flags in the school header, shared with search rows #177

Merged
tudor merged 7 commits from feat/header-facts-and-flags into main 2026-10-02 22:32:47 +00:00
7 changed files with 352 additions and 86 deletions
Showing only changes of commit ca47d08186 - Show all commits

No files matched your search

@@ -1,12 +1,13 @@
/**
* The facts row under the school name.
* The school header: one fact line (phase, ages, type, pupils), then flags
* only for what applies, then the address and the details.
*
* nursery_provision is GIAS text, not a boolean: "Has Nursery Classes",
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
* of those read as a nursery, so secondaries aged 11–18 showed "Nursery".
*/
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import type { School } from '@/lib/types';
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
@@ -35,22 +36,54 @@ function withSchool<T extends { schoolInfo: School }>(fixture: T, info: Partial<
return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
}
describe('school header nursery chip', () => {
it('shows Nursery when GIAS says the school has nursery classes', () => {
const flagList = () => screen.queryByRole('list', { name: 'Admission and provision' });
const flagLabels = () => within(flagList()!).getAllByRole('listitem').map((li) => li.textContent);
describe('school header fact line', () => {
it('states phase, ages, type and pupils, in that order', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
const line = screen.getByText('Ages 4–11').parentElement!;
expect(line).toHaveTextContent(/^PrimaryAges 4–11State school420 pupils$/);
});
it("prints the register's type name for a type in no group", () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: null, school_type: 'Secure units' }));
expect(screen.getByText('Secure units')).toBeInTheDocument();
});
it('no longer prints the GIAS establishment type', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { type_group: 'state' }));
expect(screen.queryByText('Academy converter')).not.toBeInTheDocument();
});
});
describe('school header flags', () => {
it('shows no flag list for a school with nothing to flag', () => {
renderSchoolDetail(withSchool(primaryFixture, { type_group: 'state' }));
expect(flagList()).not.toBeInTheDocument();
});
it('lists who-can-apply flags, then what the school offers', () => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, {
type_group: 'independent', admissions_policy: 'Selective', gender: 'Girls',
religious_denomination: 'Church of England', has_sixth_form: true,
}));
expect(flagLabels()).toEqual(['Fee-paying', 'Selective', "Girls' school", 'Church of England', 'Sixth form']);
});
it('shows Nursery class when GIAS says the school has nursery classes', () => {
renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
expect(screen.getByText('Nursery', { selector: 'span' })).toBeInTheDocument();
expect(flagLabels()).toEqual(['Nursery class']);
});
it.each(['No Nursery Classes', 'Not applicable', null])(
'hides Nursery when GIAS says %p',
'shows no nursery flag when GIAS says %p',
(value) => {
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
expect(screen.queryByText('Nursery', { selector: 'span' })).not.toBeInTheDocument();
expect(screen.queryByText(/^Nursery/)).not.toBeInTheDocument();
},
);
});
describe('school header single-sex chip', () => {
it.each([['Girls', "Girls' school"], ['Boys', "Boys' school"]])(
'labels a %s school with a plural possessive',
(gender, label) => {
@@ -65,3 +98,44 @@ describe('school header single-sex chip', () => {
expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
});
});
describe('school header address', () => {
it('names the council after the postcode', () => {
renderSchoolDetail(primaryFixture);
expect(screen.getByText(/TE1 1ST · Westshire/)).toBeInTheDocument();
});
it('leaves the council out when the address already names it', () => {
renderSchoolDetail(withSchool(primaryFixture, { local_authority: 'Testville' }));
expect(screen.queryByText(/· Testville/)).not.toBeInTheDocument();
});
});
describe('school header details', () => {
const detailed = {
headteacher_name: 'Mrs A Head', capacity: 426, county: 'Surrey',
parliamentary_constituency: 'Putney', religious_denomination: 'Church of England',
};
it('drops county, constituency and religious character', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.queryByText('County:')).not.toBeInTheDocument();
expect(screen.queryByText('Constituency:')).not.toBeInTheDocument();
expect(screen.queryByText('Religious character:')).not.toBeInTheDocument();
});
it('shows the capacity', () => {
renderSchoolDetail(withSchool(primaryFixture, detailed));
expect(screen.getByText('Capacity:').parentElement).toHaveTextContent('Capacity: 426');
});
it('names an academy trust', () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'BURNTWOOD TRUST' }));
expect(screen.getByText('Academy trust:').parentElement).toHaveTextContent('BURNTWOOD TRUST');
});
it("hides a trust that has the school's own name", () => {
renderSchoolDetail(withSchool(primaryFixture, { trust_name: 'TEST PRIMARY SCHOOL' }));
expect(screen.queryByText('Academy trust:')).not.toBeInTheDocument();
});
});
@@ -43,14 +43,13 @@ function withoutCensus(schoolTotal: number | null) {
describe('pupil count without a census record', () => {
it('uses the register count in the header, not the results cohort', () => {
renderSecondarySchoolDetail(withoutCensus(1462));
const pupils = screen.getByText('Pupils:').parentElement!;
expect(pupils).toHaveTextContent('1,462');
expect(pupils).not.toHaveTextContent('245');
expect(screen.getByText('1,462 pupils')).toBeInTheDocument();
expect(screen.queryByText('245 pupils')).not.toBeInTheDocument();
});
it('shows no count rather than the results cohort when the register has none', () => {
renderSecondarySchoolDetail(withoutCensus(null));
expect(screen.queryByText('Pupils:')).not.toBeInTheDocument();
expect(screen.queryByText(/^[\d,]+ pupils$/)).not.toBeInTheDocument();
expect(screen.queryByText('Total pupils')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
/**
* The facts the school header and the search rows print: a type in the search
* filter's terms, and flags only for what applies.
*/
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import type { School } from '@/lib/types';
const school = (over: Partial<School>): School =>
({ urn: 1, school_name: 'Test School', ...over }) as School;
const labels = (over: Partial<School>) => schoolFlags(school(over)).map((f) => f.label);
describe('schoolTypeLabel', () => {
it.each([
['state', 'State school'],
['independent', 'Independent school'],
['special', 'Special school (SEND)'],
['post16', 'Sixth form or college'],
['alternative', 'Alternative provision'],
])("names the %s group in the search filter's terms", (type_group, label) => {
expect(schoolTypeLabel(school({ type_group, school_type: 'Academy converter' }))).toBe(label);
});
it("prints the register's own name for a type in no group", () => {
expect(schoolTypeLabel(school({ type_group: null, school_type: 'Secure units' }))).toBe('Secure units');
});
it('returns null when there is no type at all', () => {
expect(schoolTypeLabel(school({}))).toBeNull();
});
});
describe('schoolFlags', () => {
it('flags nothing for a mixed, non-faith, non-selective state school', () => {
expect(labels({
type_group: 'state', gender: 'Mixed', admissions_policy: 'Non-selective',
religious_denomination: 'None', nursery_provision: 'No Nursery Classes',
has_sixth_form: false, phase: 'Secondary',
})).toEqual([]);
});
it('lists who-can-apply flags first, then what the school offers, in a fixed order', () => {
expect(labels({
type_group: 'independent', admissions_policy: 'Selective', gender: 'Boys',
religious_denomination: 'Christian', nursery_provision: 'Has Nursery Classes',
has_sixth_form: true, phase: 'All-through',
})).toEqual(['Fee-paying', 'Selective', "Boys' school", 'Christian', 'Nursery class', 'Sixth form']);
});
it('marks who-can-apply flags as conditions and offers as provision', () => {
const kinds = Object.fromEntries(
schoolFlags(school({ admissions_policy: 'Selective', has_sixth_form: true, phase: 'Secondary' }))
.map((f) => [f.label, f.kind]),
);
expect(kinds).toEqual({ Selective: 'condition', 'Sixth form': 'provision' });
});
it('never prints Non-selective, and never a no-faith value', () => {
expect(labels({ admissions_policy: 'Non-selective', religious_denomination: 'Does not apply' })).toEqual([]);
expect(labels({ admissions_policy: 'Not applicable', religious_denomination: 'None' })).toEqual([]);
});
it('prints the religious character as the register records it', () => {
expect(labels({ religious_denomination: 'Church of England/Methodist' })).toEqual(['Church of England/Methodist']);
});
it('does not flag a nursery class on a nursery school, or a sixth form on a post-16 one', () => {
expect(labels({ phase: 'Nursery', nursery_provision: 'Has Nursery Classes' })).toEqual([]);
expect(labels({ phase: '16 plus', has_sixth_form: true })).toEqual([]);
});
});
@@ -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>
+66
View File
@@ -0,0 +1,66 @@
/**
* The facts the school header and the search rows print, so a parent reads
* the same words in the list and on the page.
*
* Every value comes from one register field. Nothing is inferred or explained
* inline, and a missing field prints nothing.
*/
import type { School } from './types';
import { hasNurseryClasses, hasReligiousCharacter, singleSexLabel } from './utils';
/** The search filter's type groups (backend/school_groups.py), without its
* parenthesised notes: fees have a flag of their own. */
const TYPE_GROUP_LABELS: Record<string, string> = {
state: 'State school',
independent: 'Independent school',
special: 'Special school (SEND)',
post16: 'Sixth form or college',
alternative: 'Alternative provision',
};
/** The school's type in the search filter's terms, or the register's own name
* for a type in no group (secure units, online providers). */
export function schoolTypeLabel(school: Pick<School, 'type_group' | 'school_type'>): string | null {
const grouped = school.type_group ? TYPE_GROUP_LABELS[school.type_group] : undefined;
return grouped ?? (school.school_type?.trim() || null);
}
/** "condition": changes who can apply or what it costs.
* "provision": what the school offers. */
export type SchoolFlagKind = 'condition' | 'provision';
export interface SchoolFlag {
label: string;
kind: SchoolFlagKind;
}
type FlagFields = Pick<
School,
'type_group' | 'admissions_policy' | 'gender' | 'religious_denomination'
| 'nursery_provision' | 'has_sixth_form' | 'phase'
>;
/**
* Flags for what applies, in a fixed order: conditions first, then provision.
*
* Selective needs the exact value. The register files a partly selective
* school as "Non-selective", so that value is never printed.
*/
export function schoolFlags(school: FlagFields): SchoolFlag[] {
const flags: SchoolFlag[] = [];
const condition = (label: string) => flags.push({ label, kind: 'condition' });
const provision = (label: string) => flags.push({ label, kind: 'provision' });
const phase = school.phase?.trim().toLowerCase();
if (school.type_group === 'independent') condition('Fee-paying');
if (school.admissions_policy?.trim().toLowerCase() === 'selective') condition('Selective');
const singleSex = singleSexLabel(school.gender);
if (singleSex) condition(singleSex);
if (hasReligiousCharacter(school.religious_denomination)) {
condition(school.religious_denomination!.trim());
}
if (hasNurseryClasses(school.nursery_provision) && phase !== 'nursery') provision('Nursery class');
if (school.has_sixth_form && phase !== '16 plus') provision('Sixth form');
return flags;
}
+2
View File
@@ -17,6 +17,8 @@ export interface School {
local_authority_code: number | null;
school_type: string | null;
school_type_code: string | null;
/** Search-filter type group ("state", "independent"…), null for a type in none. */
type_group?: string | null;
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;