): T {
+ return { ...fixture, schoolInfo: { ...fixture.schoolInfo, ...info } };
+}
+
+describe('school header nursery chip', () => {
+ it('shows Nursery when GIAS says the school has nursery classes', () => {
+ renderSchoolDetail(withSchool(primaryFixture, { nursery_provision: 'Has Nursery Classes' }));
+ expect(screen.getByText('Nursery', { selector: 'span' })).toBeInTheDocument();
+ });
+
+ it.each(['No Nursery Classes', 'Not applicable', null])(
+ 'hides Nursery when GIAS says %p',
+ (value) => {
+ renderSecondarySchoolDetail(withSchool(secondaryFixture, { nursery_provision: value }));
+ expect(screen.queryByText('Nursery', { selector: 'span' })).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) => {
+ renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender }));
+ expect(screen.getByText(label)).toBeInTheDocument();
+ expect(screen.queryByText(/'s school/)).not.toBeInTheDocument();
+ },
+ );
+
+ it('says nothing for a mixed school', () => {
+ renderSecondarySchoolDetail(withSchool(secondaryFixture, { gender: 'Mixed' }));
+ expect(screen.queryByText(/^(Girls|Boys|Mixed)'s? school$/)).not.toBeInTheDocument();
+ });
+});
diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts
index b5f74a4..9fb5042 100644
--- a/nextjs-app/__tests__/lib/utils.test.ts
+++ b/nextjs-app/__tests__/lib/utils.test.ts
@@ -15,6 +15,8 @@ import {
computeYBounds,
formatAgeRange,
formatAgeSpan,
+ hasNurseryClasses,
+ singleSexLabel,
} from '@/lib/utils';
describe('formatPercentage', () => {
@@ -346,3 +348,32 @@ describe('formatAgeRange', () => {
expect(formatAgeRange('4-11')).toBe('Ages 4–11');
});
});
+
+describe('hasNurseryClasses', () => {
+ it('is true only for the GIAS value that means it', () => {
+ // GIAS sends text, and two of its three values mean no nursery.
+ expect(hasNurseryClasses('Has Nursery Classes')).toBe(true);
+ expect(hasNurseryClasses('No Nursery Classes')).toBe(false);
+ expect(hasNurseryClasses('Not applicable')).toBe(false);
+ expect(hasNurseryClasses(null)).toBe(false);
+ expect(hasNurseryClasses(undefined)).toBe(false);
+ });
+});
+
+describe('singleSexLabel', () => {
+ it('uses the plural possessive GIAS values need', () => {
+ expect(singleSexLabel('Girls')).toBe("Girls' school");
+ expect(singleSexLabel('Boys')).toBe("Boys' school");
+ });
+
+ it('ignores case, as hasNurseryClasses does', () => {
+ expect(singleSexLabel(' girls ')).toBe("Girls' school");
+ expect(singleSexLabel('BOYS')).toBe("Boys' school");
+ });
+
+ it('returns null for a mixed or unknown school', () => {
+ expect(singleSexLabel('Mixed')).toBeNull();
+ expect(singleSexLabel(null)).toBeNull();
+ expect(singleSexLabel(undefined)).toBeNull();
+ });
+});
diff --git a/nextjs-app/components/places/PlaceView.tsx b/nextjs-app/components/places/PlaceView.tsx
index b27c935..1012864 100644
--- a/nextjs-app/components/places/PlaceView.tsx
+++ b/nextjs-app/components/places/PlaceView.tsx
@@ -13,7 +13,7 @@ import Link from 'next/link';
import type { PlaceDetail, PlaceSummary } from '@/lib/places';
import { placeUrl, authoritySlug } from '@/lib/places';
import type { School } from '@/lib/types';
-import { schoolUrl, formatAgeSpan } from '@/lib/utils';
+import { schoolUrl, formatAgeSpan, hasNurseryClasses } from '@/lib/utils';
import { absoluteUrl } from '@/lib/site';
import { TrackPlaceView } from './TrackPlaceView';
import styles from './PlaceView.module.css';
@@ -131,9 +131,10 @@ function SchoolTable({ schools, phase }: { schools: School[]; phase: PhaseKey })
{showNursery && (
|
{/* Undefined is a mart the pipeline has not rebuilt, and
- false is a school without one. Neither is a "Yes", and
- neither is worth two different words. */}
- {s.nursery_provision ? 'Yes' : NO_VALUE}
+ "No Nursery Classes" or "Not applicable" is a school
+ without one. None is a "Yes", and none is worth a
+ different word. */}
+ {hasNurseryClasses(s.nursery_provision) ? 'Yes' : NO_VALUE}
|
)}
diff --git a/nextjs-app/components/school/SchoolDetailShell.tsx b/nextjs-app/components/school/SchoolDetailShell.tsx
index ff031ca..fe9b7ab 100644
--- a/nextjs-app/components/school/SchoolDetailShell.tsx
+++ b/nextjs-app/components/school/SchoolDetailShell.tsx
@@ -21,7 +21,7 @@ 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, isProposedToClose } from '@/lib/utils';
+import { formatAgeRange, hasNurseryClasses, isProposedToClose, singleSexLabel } from '@/lib/utils';
import type { NavItem } from '@/lib/schoolSections';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailShell.module.css';
@@ -122,7 +122,7 @@ export function SchoolDetailShell({
return () => window.removeEventListener('keydown', onKey);
}, [sectionsOpen]);
- // The chrome needs only these four. The section-shape flags are computed
+ // The chrome needs only these few. The section-shape flags are computed
// once on the server (lib/schoolSections) and consumed by the section
// composers; recomputing them here would duplicate that work for values
// this component never renders.
@@ -130,6 +130,7 @@ export function SchoolDetailShell({
const phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through';
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
+ const singleSex = singleSexLabel(schoolInfo.gender);
const handleComparisonToggle = () => {
if (isInComparison) {
@@ -214,13 +215,11 @@ export function SchoolDetailShell({
{isAllThrough && (
All-through (primary & secondary)
)}
- {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
- {schoolInfo.gender}'s school
- )}
+ {singleSex && {singleSex}}
{schoolInfo.age_range && (
{formatAgeRange(schoolInfo.age_range)}
)}
- {schoolInfo.nursery_provision && (
+ {hasNurseryClasses(schoolInfo.nursery_provision) && (
Nursery
)}
{schoolInfo.has_sixth_form && (
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index 47fa1c9..93d31a8 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -20,7 +20,8 @@ export interface School {
religious_denomination: string | null;
age_range: string | null;
has_sixth_form?: boolean | null;
- nursery_provision?: boolean | null;
+ /** GIAS text; read it through hasNurseryClasses(). */
+ nursery_provision?: string | null;
status?: string | null; // GIAS establishment status ("Open" / "Open, but proposed to close")
// Address
diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts
index 3422edb..f794260 100644
--- a/nextjs-app/lib/utils.ts
+++ b/nextjs-app/lib/utils.ts
@@ -99,6 +99,26 @@ export function formatAgeRange(ageRange: string | null | undefined): string {
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
}
+/**
+ * GIAS NurseryProvision is text: "Has Nursery Classes", "No Nursery Classes"
+ * or "Not applicable". Only the first means a nursery, so never test the raw
+ * value for truthiness.
+ */
+export function hasNurseryClasses(value: string | null | undefined): boolean {
+ return value?.trim().toLowerCase() === 'has nursery classes';
+}
+
+/**
+ * "Girls' school" / "Boys' school" for a single-sex school, null otherwise.
+ * GIAS genders are plural, so the possessive is a bare apostrophe.
+ */
+export function singleSexLabel(gender: string | null | undefined): string | null {
+ const g = gender?.trim().toLowerCase();
+ if (g === 'girls') return "Girls' school";
+ if (g === 'boys') return "Boys' school";
+ return null;
+}
+
// ============================================================================
// Number Formatting
// ============================================================================
|