diff --git a/nextjs-app/__tests__/components/schoolDetailHeader.test.tsx b/nextjs-app/__tests__/components/schoolDetailHeader.test.tsx index 3fc3b6a..3b462e2 100644 --- a/nextjs-app/__tests__/components/schoolDetailHeader.test.tsx +++ b/nextjs-app/__tests__/components/schoolDetailHeader.test.tsx @@ -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(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(); + }); +}); diff --git a/nextjs-app/__tests__/components/schoolPupilCount.test.tsx b/nextjs-app/__tests__/components/schoolPupilCount.test.tsx index d5d792f..018992c 100644 --- a/nextjs-app/__tests__/components/schoolPupilCount.test.tsx +++ b/nextjs-app/__tests__/components/schoolPupilCount.test.tsx @@ -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(); }); }); diff --git a/nextjs-app/__tests__/lib/schoolFacts.test.ts b/nextjs-app/__tests__/lib/schoolFacts.test.ts new file mode 100644 index 0000000..575944f --- /dev/null +++ b/nextjs-app/__tests__/lib/schoolFacts.test.ts @@ -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 => + ({ urn: 1, school_name: 'Test School', ...over }) as School; + +const labels = (over: Partial) => 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([]); + }); +}); diff --git a/nextjs-app/components/school/SchoolDetailShell.module.css b/nextjs-app/components/school/SchoolDetailShell.module.css index c519195..90650a4 100644 --- a/nextjs-app/components/school/SchoolDetailShell.module.css +++ b/nextjs-app/components/school/SchoolDetailShell.module.css @@ -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 { diff --git a/nextjs-app/components/school/SchoolDetailShell.tsx b/nextjs-app/components/school/SchoolDetailShell.tsx index ba09d1b..c0c0f2d 100644 --- a/nextjs-app/components/school/SchoolDetailShell.tsx +++ b/nextjs-app/components/school/SchoolDetailShell.tsx @@ -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(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({

{schoolInfo.school_name}

-
- {schoolInfo.local_authority && ( - {schoolInfo.local_authority} - )} - {schoolInfo.school_type && ( - {schoolInfo.school_type} - )} - {isAllThrough && ( - All-through (primary & secondary) - )} - {singleSex && {singleSex}} - {schoolInfo.age_range && ( - {formatAgeRange(schoolInfo.age_range)} - )} - {hasNurseryClasses(schoolInfo.nursery_provision) && ( - Nursery - )} - {schoolInfo.has_sixth_form && ( - Sixth form - )} -
+ {(phasePill.label || facts.length > 0) && ( +
+ {phasePill.label && ( + + {phasePill.label} + + )} + {facts.map((fact) => ( + {fact} + ))} +
+ )} + {flags.length > 0 && ( + // role="list": list-style: none drops list semantics in Safari. +
    + {flags.map((flag) => ( +
  • + {flag.label} +
  • + ))} +
+ )} {isProposedToClose(schoolInfo) && (
⚠ Proposed to close. Check with the local authority before @@ -232,6 +262,7 @@ export function SchoolDetailShell({ {schoolInfo.address && (

{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`} + {council && ` · ${council}`} {hasLocation && ( <> {' · '} @@ -278,23 +309,6 @@ export function SchoolDetailShell({ )} - {(() => { - // 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 ( - - Pupils: {total.toLocaleString()} - {schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`} - - ); - })()} - {schoolInfo.trust_name && ( - - Part of {schoolInfo.trust_name} - - )} {schoolInfo.telephone && ( Phone:{' '} @@ -303,22 +317,14 @@ export function SchoolDetailShell({ )} - {schoolInfo.religious_denomination && ( + {trust && ( - Religious character:{' '} - {['Does not apply', 'None'].includes(schoolInfo.religious_denomination) - ? 'None' - : schoolInfo.religious_denomination} + Academy trust: {trust} )} - {schoolInfo.county && ( + {schoolInfo.capacity != null && ( - County: {schoolInfo.county} - - )} - {schoolInfo.parliamentary_constituency && ( - - Constituency: {schoolInfo.parliamentary_constituency} + Capacity: {schoolInfo.capacity.toLocaleString()} )}

diff --git a/nextjs-app/lib/schoolFacts.ts b/nextjs-app/lib/schoolFacts.ts new file mode 100644 index 0000000..24e1ce2 --- /dev/null +++ b/nextjs-app/lib/schoolFacts.ts @@ -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 = { + 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): 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; +} diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 93d31a8..11c5582 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -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;