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:
1 parent
62a6bfaf0c
commit
ca47d08186
7 files changed
+352
-86
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",
|
* nursery_provision is GIAS text, not a boolean: "Has Nursery Classes",
|
||||||
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
|
* "No Nursery Classes" or "Not applicable". Tested for truthiness, every one
|
||||||
* of those read as a nursery, so secondaries aged 11–18 showed "Nursery".
|
* 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 type { School } from '@/lib/types';
|
||||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
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 } };
|
return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('school header nursery chip', () => {
|
const flagList = () => screen.queryByRole('list', { name: 'Admission and provision' });
|
||||||
it('shows Nursery when GIAS says the school has nursery classes', () => {
|
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' }));
|
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])(
|
it.each(['No Nursery Classes', 'Not applicable', null])(
|
||||||
'hides Nursery when GIAS says %p',
|
'shows no nursery flag when GIAS says %p',
|
||||||
(value) => {
|
(value) => {
|
||||||
renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: 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"]])(
|
it.each([['Girls', "Girls' school"], ['Boys', "Boys' school"]])(
|
||||||
'labels a %s school with a plural possessive',
|
'labels a %s school with a plural possessive',
|
||||||
(gender, label) => {
|
(gender, label) => {
|
||||||
@@ -65,3 +98,44 @@ describe('school header single-sex chip', () => {
|
|||||||
expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
|
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', () => {
|
describe('pupil count without a census record', () => {
|
||||||
it('uses the register count in the header, not the results cohort', () => {
|
it('uses the register count in the header, not the results cohort', () => {
|
||||||
renderSecondarySchoolDetail(withoutCensus(1462));
|
renderSecondarySchoolDetail(withoutCensus(1462));
|
||||||
const pupils = screen.getByText('Pupils:').parentElement!;
|
expect(screen.getByText('1,462 pupils')).toBeInTheDocument();
|
||||||
expect(pupils).toHaveTextContent('1,462');
|
expect(screen.queryByText('245 pupils')).not.toBeInTheDocument();
|
||||||
expect(pupils).not.toHaveTextContent('245');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no count rather than the results cohort when the register has none', () => {
|
it('shows no count rather than the results cohort when the register has none', () => {
|
||||||
renderSecondarySchoolDetail(withoutCensus(null));
|
renderSecondarySchoolDetail(withoutCensus(null));
|
||||||
expect(screen.queryByText('Pupils:')).not.toBeInTheDocument();
|
expect(screen.queryByText(/^[\d,]+ pupils$/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Total 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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
|
row-gap: 0.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.metaItem {
|
.fact + .fact::before {
|
||||||
font-size: 0.8125rem;
|
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);
|
color: var(--text-secondary);
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -703,17 +759,8 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pills wrap horizontally instead of stacking — short tokens like
|
/* Secondary header info (headteacher, website, phone, trust, capacity)
|
||||||
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
isn't needed above the fold on phones/tablets, so it's
|
||||||
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
|
|
||||||
collapsed by default and revealed on demand via the "Show all details"
|
collapsed by default and revealed on demand via the "Show all details"
|
||||||
link — reclaiming the vertical space so the metrics surface sooner. */
|
link — reclaiming the vertical space so the metrics surface sooner. */
|
||||||
.detailsToggle {
|
.detailsToggle {
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
import { SchoolHeroMap, type SchoolHeroMapHandle } from '../SchoolHeroMap';
|
||||||
import type { School, SchoolResult, SchoolCensus } from '@/lib/types';
|
import type { School, SchoolCensus } from '@/lib/types';
|
||||||
import { formatAgeRange, hasNurseryClasses, isProposedToClose, singleSexLabel } from '@/lib/utils';
|
import { formatAgeRange, getPhaseStyle, isProposedToClose } from '@/lib/utils';
|
||||||
|
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
|
||||||
import type { NavItem } from '@/lib/schoolSections';
|
import type { NavItem } from '@/lib/schoolSections';
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SchoolDetailShell.module.css';
|
import styles from './SchoolDetailShell.module.css';
|
||||||
@@ -41,6 +42,12 @@ export interface SchoolDetailShellProps {
|
|||||||
children: ReactNode;
|
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({
|
export function SchoolDetailShell({
|
||||||
schoolInfo, census, navItems, children,
|
schoolInfo, census, navItems, children,
|
||||||
}: SchoolDetailShellProps) {
|
}: SchoolDetailShellProps) {
|
||||||
@@ -61,7 +68,7 @@ export function SchoolDetailShell({
|
|||||||
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
const heroMapRef = useRef<SchoolHeroMapHandle>(null);
|
||||||
// "All ▾" jump menu listing every section.
|
// "All ▾" jump menu listing every section.
|
||||||
const [sectionsOpen, setSectionsOpen] = useState(false);
|
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.
|
// "Show all details" link on mobile/tablet, where they're below the fold.
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -125,9 +132,28 @@ export function SchoolDetailShell({
|
|||||||
// composers; recomputing them here would duplicate that work for values
|
// composers; recomputing them here would duplicate that work for values
|
||||||
// this component never renders.
|
// this component never renders.
|
||||||
const phase = schoolInfo.phase ?? '';
|
const phase = schoolInfo.phase ?? '';
|
||||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
|
||||||
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
|
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 = () => {
|
const handleComparisonToggle = () => {
|
||||||
if (isInComparison) {
|
if (isInComparison) {
|
||||||
@@ -202,27 +228,31 @@ export function SchoolDetailShell({
|
|||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<div className={styles.titleSection}>
|
<div className={styles.titleSection}>
|
||||||
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||||
<div className={styles.meta}>
|
{(phasePill.label || facts.length > 0) && (
|
||||||
{schoolInfo.local_authority && (
|
<div className={styles.facts}>
|
||||||
<span className={styles.metaItem}>{schoolInfo.local_authority}</span>
|
{phasePill.label && (
|
||||||
)}
|
<span className={`${styles.phasePill} ${styles[`phasePill${phasePill.key}`]}`}>
|
||||||
{schoolInfo.school_type && (
|
{phasePill.label}
|
||||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAllThrough && (
|
{facts.map((fact) => (
|
||||||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
<span key={fact} className={styles.fact}>{fact}</span>
|
||||||
)}
|
))}
|
||||||
{singleSex && <span className={styles.metaItem}>{singleSex}</span>}
|
</div>
|
||||||
{schoolInfo.age_range && (
|
)}
|
||||||
<span className={styles.metaItem}>{formatAgeRange(schoolInfo.age_range)}</span>
|
{flags.length > 0 && (
|
||||||
)}
|
// role="list": list-style: none drops list semantics in Safari.
|
||||||
{hasNurseryClasses(schoolInfo.nursery_provision) && (
|
<ul role="list" className={styles.flags} aria-label="Admission and provision">
|
||||||
<span className={styles.metaItem}>Nursery</span>
|
{flags.map((flag) => (
|
||||||
)}
|
<li
|
||||||
{schoolInfo.has_sixth_form && (
|
key={flag.label}
|
||||||
<span className={styles.metaItem}>Sixth form</span>
|
className={flag.kind === 'condition' ? styles.flagCondition : styles.flagProvision}
|
||||||
)}
|
>
|
||||||
</div>
|
{flag.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
{isProposedToClose(schoolInfo) && (
|
{isProposedToClose(schoolInfo) && (
|
||||||
<div className={styles.closingStrip} role="note">
|
<div className={styles.closingStrip} role="note">
|
||||||
<strong>⚠ Proposed to close.</strong> Check with the local authority before
|
<strong>⚠ Proposed to close.</strong> Check with the local authority before
|
||||||
@@ -232,6 +262,7 @@ export function SchoolDetailShell({
|
|||||||
{schoolInfo.address && (
|
{schoolInfo.address && (
|
||||||
<p className={styles.address}>
|
<p className={styles.address}>
|
||||||
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||||
|
{council && ` · ${council}`}
|
||||||
{hasLocation && (
|
{hasLocation && (
|
||||||
<>
|
<>
|
||||||
{' · '}
|
{' · '}
|
||||||
@@ -278,23 +309,6 @@ export function SchoolDetailShell({
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</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 && (
|
{schoolInfo.telephone && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>Phone:</strong>{' '}
|
<strong>Phone:</strong>{' '}
|
||||||
@@ -303,22 +317,14 @@ export function SchoolDetailShell({
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{schoolInfo.religious_denomination && (
|
{trust && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>Religious character:</strong>{' '}
|
<strong>Academy trust:</strong> {trust}
|
||||||
{['Does not apply', 'None'].includes(schoolInfo.religious_denomination)
|
|
||||||
? 'None'
|
|
||||||
: schoolInfo.religious_denomination}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{schoolInfo.county && (
|
{schoolInfo.capacity != null && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>County:</strong> {schoolInfo.county}
|
<strong>Capacity:</strong> {schoolInfo.capacity.toLocaleString()}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{schoolInfo.parliamentary_constituency && (
|
|
||||||
<span className={styles.headerDetail}>
|
|
||||||
<strong>Constituency:</strong> {schoolInfo.parliamentary_constituency}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export interface School {
|
|||||||
local_authority_code: number | null;
|
local_authority_code: number | null;
|
||||||
school_type: string | null;
|
school_type: string | null;
|
||||||
school_type_code: 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;
|
religious_denomination: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
has_sixth_form?: boolean | null;
|
has_sixth_form?: boolean | null;
|
||||||
|
|||||||
Reference in new issue
Block a user