feat(search): print the school page's type and flags in search rows

Both rows now take their type label and tags from lib/schoolFacts, so a
parent reads the same words in the list and on the page: "State school"
instead of "Academy converter", "Girls' school" instead of "Girls", the
religious character instead of "Faith priority", and a Nursery class flag
on primaries. Who-can-apply tags are outlined with an inset ring, which
keeps them the size of their neighbours; the Selective tag no longer
borrows the status colour.

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 ca47d08186
commit ad9d3b67f7
6 files changed
+88 -42

No files matched your search

@@ -34,3 +34,21 @@ describe('SchoolRow religious character', () => {
expect(screen.getByText('Church of England')).toBeInTheDocument(); expect(screen.getByText('Church of England')).toBeInTheDocument();
}); });
}); });
describe('SchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Free schools')).not.toBeInTheDocument();
});
it('flags a nursery class', () => {
render(<SchoolRow school={{ ...base, nursery_provision: 'Has Nursery Classes' }} />);
expect(screen.getByText('Nursery class')).toBeInTheDocument();
});
it("flags a boys' school", () => {
render(<SchoolRow school={{ ...base, gender: 'Boys' }} />);
expect(screen.getByText("Boys' school")).toBeInTheDocument();
});
});
@@ -79,7 +79,7 @@ describe('SecondarySchoolRow admissions tag', () => {
expect(screen.queryByText('Selective')).not.toBeInTheDocument(); expect(screen.queryByText('Selective')).not.toBeInTheDocument();
}); });
it.each(['None', 'Does not apply', '', null])( it.each(['None', 'Does not apply'])(
'gives no faith tag when the religious character is %p', 'gives no faith tag when the religious character is %p',
(religious_denomination) => { (religious_denomination) => {
render( render(
@@ -87,16 +87,32 @@ describe('SecondarySchoolRow admissions tag', () => {
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination }} school={{ ...base, admissions_policy: 'Not applicable', religious_denomination }}
/>, />,
); );
expect(screen.queryByText(religious_denomination)).not.toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument(); expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
}, },
); );
it('tags a school with a religious character', () => { it('tags the religious character the register records, not "Faith priority"', () => {
render( render(
<SecondarySchoolRow <SecondarySchoolRow
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination: 'Church of England' }} school={{ ...base, admissions_policy: 'Not applicable', religious_denomination: 'Church of England' }}
/>, />,
); );
expect(screen.getByText('Faith priority')).toBeInTheDocument(); expect(screen.getByText('Church of England')).toBeInTheDocument();
expect(screen.queryByText('Faith priority')).not.toBeInTheDocument();
});
});
describe('SecondarySchoolRow shares the school page flags', () => {
it("prints the type in the search filter's terms", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'state' }} />);
expect(screen.getByText('State school')).toBeInTheDocument();
expect(screen.queryByText('Academy')).not.toBeInTheDocument();
});
it("flags a girls' school and fees", () => {
render(<SecondarySchoolRow school={{ ...base, type_group: 'independent', gender: 'Girls' }} />);
expect(screen.getByText("Girls' school")).toBeInTheDocument();
expect(screen.getByText('Fee-paying')).toBeInTheDocument();
}); });
}); });
@@ -100,6 +100,19 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
a verdict. An inset ring keeps the box the size of its neighbours. */
.conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
}
/* Line 3: stats */ /* Line 3: stats */
.line3 { .line3 {
display: flex; display: flex;
+14 -7
View File
@@ -3,13 +3,14 @@
* Four-line row for primary school search results * Four-line row for primary school search results
* *
* Line 1: School name · Ofsted badge (framework-aware) * Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender * Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils * Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance * Line 4: Local authority · Distance
*/ */
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, hasReligiousCharacter, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils'; import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SchoolRow.module.css'; import styles from './SchoolRow.module.css';
interface SchoolRowProps { interface SchoolRowProps {
@@ -33,8 +34,8 @@ export function SchoolRow({
const phase = getPhaseStyle(school.phase); const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school); const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed'; const typeLabel = schoolTypeLabel(school);
const showDenomination = hasReligiousCharacter(school.religious_denomination); const flags = schoolFlags(school);
// The school's OWN figure and its year-over-year trend are same-school // The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value (not the all-zero // measures — shown whenever there's a real value (not the all-zero
@@ -77,10 +78,16 @@ export function SchoolRow({
{phase.label} {phase.label}
</span> </span>
)} )}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>} {typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>} {flags.map((flag) => (
{showGender && <span className={styles.attr}>{school.gender}</span>} <span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.attr}
>
{flag.label}
</span>
))}
{isProposedToClose(school) && ( {isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span> <span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
)} )}
@@ -183,9 +183,17 @@
white-space: nowrap; white-space: nowrap;
} }
.selectiveTag { /* Changes who can apply or what it costs. Outlined, not tinted: a fact, not
background: rgba(var(--status-below-rgb), 0.1); a verdict. An inset ring keeps the box the size of its neighbours. */
color: var(--status-below); .conditionTag {
display: inline-block;
padding: 0.0625rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(var(--ink-rgb), 0.4);
color: var(--text-primary);
} }
/* ── Ofsted badge ────────────────────────────────────── */ /* ── Ofsted badge ────────────────────────────────────── */
+13 -29
View File
@@ -3,7 +3,7 @@
* Four-line row for secondary school search results * Four-line row for secondary school search results
* *
* Line 1: School name · Ofsted badge * Line 1: School name · Ofsted badge
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag * Line 2: Phase · Type · Age range · the school page's flags (lib/schoolFacts)
* Line 3: Attainment 8 (large) · ±LA avg delta · Pupils * Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
* Line 4: LA name · distance * Line 4: LA name · distance
*/ */
@@ -11,22 +11,10 @@
'use client'; 'use client';
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, hasReligiousCharacter, isProposedToClose, isSpecialSchool } from '@/lib/utils'; import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import { schoolFlags, schoolTypeLabel } from '@/lib/schoolFacts';
import styles from './SecondarySchoolRow.module.css'; import styles from './SecondarySchoolRow.module.css';
function detectAdmissionsTag(school: School): string | null {
// Exact match: "Non-selective" contains "selective", so a substring test
// tagged every comprehensive as Selective.
if (school.admissions_policy?.trim().toLowerCase() === 'selective') return 'Selective';
if (hasReligiousCharacter(school.religious_denomination)) return 'Faith priority';
return null;
}
function hasSixthForm(school: School): boolean {
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
return school.has_sixth_form ?? false;
}
interface SecondarySchoolRowProps { interface SecondarySchoolRowProps {
school: School; school: School;
isLocationSearch?: boolean; isLocationSearch?: boolean;
@@ -64,9 +52,8 @@ export function SecondarySchoolRow({
? att8 - laAvgAttainment8 ? att8 - laAvgAttainment8
: null; : null;
const admissionsTag = detectAdmissionsTag(school); const typeLabel = schoolTypeLabel(school);
const sixthForm = hasSixthForm(school); const flags = schoolFlags(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
return ( return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}> <div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
@@ -90,19 +77,16 @@ export function SecondarySchoolRow({
{phase.label} {phase.label}
</span> </span>
)} )}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>} {typeLabel && <span className={styles.attr}>{typeLabel}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>} {school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showGender && ( {flags.map((flag) => (
<span className={styles.provisionTag}>{school.gender}</span> <span
)} key={flag.label}
{sixthForm && ( className={flag.kind === 'condition' ? styles.conditionTag : styles.provisionTag}
<span className={styles.provisionTag}>Sixth form</span> >
)} {flag.label}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
</span> </span>
)} ))}
{isProposedToClose(school) && ( {isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span> <span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
)} )}