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();
});
});
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();
});
it.each(['None', 'Does not apply', '', null])(
it.each(['None', 'Does not apply'])(
'gives no faith tag when the religious character is %p',
(religious_denomination) => {
render(
@@ -87,16 +87,32 @@ describe('SecondarySchoolRow admissions tag', () => {
school={{ ...base, admissions_policy: 'Not applicable', religious_denomination }}
/>,
);
expect(screen.queryByText(religious_denomination)).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(
<SecondarySchoolRow
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);
}
/* 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 */
.line3 {
display: flex;
+14 -7
View File
@@ -3,13 +3,14 @@
* Four-line row for primary school search results
*
* 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 4: Local authority · Distance
*/
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';
interface SchoolRowProps {
@@ -33,8 +34,8 @@ export function SchoolRow({
const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination = hasReligiousCharacter(school.religious_denomination);
const typeLabel = schoolTypeLabel(school);
const flags = schoolFlags(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
@@ -77,10 +78,16 @@ export function SchoolRow({
{phase.label}
</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>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{flags.map((flag) => (
<span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.attr}
>
{flag.label}
</span>
))}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}>⚠ Proposed to close</span>
)}
@@ -183,9 +183,17 @@
white-space: nowrap;
}
.selectiveTag {
background: rgba(var(--status-below-rgb), 0.1);
color: var(--status-below);
/* 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);
}
/* ── Ofsted badge ────────────────────────────────────── */
+13 -29
View File
@@ -3,7 +3,7 @@
* Four-line row for secondary school search results
*
* 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 4: LA name · distance
*/
@@ -11,22 +11,10 @@
'use client';
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';
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 {
school: School;
isLocationSearch?: boolean;
@@ -64,9 +52,8 @@ export function SecondarySchoolRow({
? att8 - laAvgAttainment8
: null;
const admissionsTag = detectAdmissionsTag(school);
const sixthForm = hasSixthForm(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const typeLabel = schoolTypeLabel(school);
const flags = schoolFlags(school);
return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
@@ -90,19 +77,16 @@ export function SecondarySchoolRow({
{phase.label}
</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>}
{showGender && (
<span className={styles.provisionTag}>{school.gender}</span>
)}
{sixthForm && (
<span className={styles.provisionTag}>Sixth form</span>
)}
{admissionsTag && (
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
{admissionsTag}
{flags.map((flag) => (
<span
key={flag.label}
className={flag.kind === 'condition' ? styles.conditionTag : styles.provisionTag}
>
{flag.label}
</span>
)}
))}
{isProposedToClose(school) && (
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
)}