feat: similar schools nearby on the detail page #150

Merged
tudor merged 12 commits from feat/similar-schools-nearby into main 2026-09-22 05:53:07 +00:00
5 changed files with 86 additions and 5 deletions
Showing only changes of commit 4e0d8bcf87 - Show all commits

No files matched your search

@@ -172,3 +172,39 @@ describe('buildSecondaryNavItems', () => {
expect(ids).not.toContain('history');
});
});
describe('the similar-schools nav item', () => {
const navInput = {
ofsted: null, admissions: null, admissionDistance: null,
hasLocation: true, yearlyDataLength: 1,
};
it('appears on both templates when the section renders', () => {
const primary = computeSchoolFlags(primaryFixture);
const secondary = computeSecondaryFlags(secondaryFixture);
const input = { ...navInput, hasSimilarSchools: true };
expect(buildNavItems(primary, input).map((i) => i.id)).toContain('similar');
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).toContain('similar');
});
it('is absent when the section does not render', () => {
const primary = computeSchoolFlags(primaryFixture);
const secondary = computeSecondaryFlags(secondaryFixture);
const input = { ...navInput, hasSimilarSchools: false };
expect(buildNavItems(primary, input).map((i) => i.id)).not.toContain('similar');
expect(buildSecondaryNavItems(secondary, input).map((i) => i.id)).not.toContain('similar');
});
it('is absent when nothing says either way', () => {
const primary = computeSchoolFlags(primaryFixture);
expect(buildNavItems(primary, navInput).map((i) => i.id)).not.toContain('similar');
});
it('comes last, because the section renders last', () => {
const primary = computeSchoolFlags(primaryFixture);
const ids = buildNavItems(primary, { ...navInput, hasSimilarSchools: true }).map((i) => i.id);
expect(ids[ids.length - 1]).toBe('similar');
});
});
@@ -8,6 +8,7 @@ import { APIFetchError, fetchSchoolDetails, fetchSchools, fetchNationalAverages
import { notFound, redirect } from 'next/navigation';
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
import { shouldRenderSimilar } from '@/components/school/SimilarSchoolsSection';
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
@@ -155,6 +156,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
// nothing rather than throwing, which is how this shipped without a
// lockstep deploy of the two images.
const places: SchoolPlace[] = data.places ?? [];
// Absent on an older API build, exactly like `places` above.
const similarSchools = data.similar_schools ?? [];
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -186,6 +189,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
admissions: admissions ?? null,
admissionDistance: admission_distance ?? null,
hasLocation: school_info.latitude != null && school_info.longitude != null,
hasSimilarSchools: shouldRenderSimilar(similarSchools),
yearlyDataLength: yearly_data.length,
};
const primaryNavItems = buildNavItems(primaryFlags, navInput);
@@ -262,6 +266,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
finance={finance ?? null}
nationalAvg={nationalAvg}
destinations={destinations ?? null}
similarSchools={similarSchools}
flags={secondaryFlags}
/>
</SchoolDetailShell>
@@ -284,6 +289,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
deprivation={deprivation ?? null}
finance={finance ?? null}
nationalAvg={nationalAvg}
similarSchools={similarSchools}
flags={primaryFlags}
/>
</SchoolDetailShell>
@@ -14,6 +14,7 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
SimilarSchool,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SchoolFlags } from '@/lib/schoolSections';
@@ -26,6 +27,7 @@ import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection';
import { LocalAreaSection } from './LocalAreaSection';
import { FinancesSection } from './FinancesSection';
import { SimilarSchoolsSection } from './SimilarSchoolsSection';
export interface PrimarySchoolSectionsProps {
schoolInfo: School;
@@ -39,13 +41,15 @@ export interface PrimarySchoolSectionsProps {
deprivation: SchoolDeprivation | null;
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
/** Nearby schools of a comparable intake. Absent on an older API build. */
similarSchools?: SimilarSchool[];
flags: SchoolFlags;
}
export function PrimarySchoolSections({
schoolInfo, yearlyData, absenceData, ofsted, census,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, flags,
deprivation, finance, nationalAvg, similarSchools, flags,
}: PrimarySchoolSectionsProps) {
const primaryAvg = nationalAvg?.primary ?? {};
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -146,6 +150,15 @@ export function PrimarySchoolSections({
)}
{flags.hasFinance && finance && <FinancesSection finance={finance} />}
{/* Last: it is where the reader goes next, not part of this school. */}
<SimilarSchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phaseNoun={flags.isAllThrough ? 'all-through' : 'primary'}
thisMetricValue={flags.latestResults?.rwm_expected_pct ?? null}
similar={similarSchools}
/>
</>
);
}
@@ -14,7 +14,7 @@
import type {
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
SchoolDestinations,
SchoolDestinations, SimilarSchool,
} from '@/lib/types';
import { ofstedLegacyAreas } from '@/lib/utils';
import type { SecondaryFlags } from '@/lib/schoolSections';
@@ -27,6 +27,7 @@ import { DistanceSection } from './DistanceSection';
import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection';
import { SimilarSchoolsSection } from './SimilarSchoolsSection';
import styles from './schoolSections.module.css';
export interface SecondarySchoolSectionsProps {
@@ -47,13 +48,15 @@ export interface SecondarySchoolSectionsProps {
finance: SchoolFinance | null;
nationalAvg: NationalAverages | null;
destinations: SchoolDestinations | null;
/** Nearby schools of a comparable intake. Absent on an older API build. */
similarSchools?: SimilarSchool[];
flags: SecondaryFlags;
}
export function SecondarySchoolSections({
schoolInfo, yearlyData, ofsted, census,
admissions, admissionsHistory, admissionDistance,
deprivation, finance, nationalAvg, destinations, flags,
deprivation, finance, nationalAvg, destinations, similarSchools, flags,
}: SecondarySchoolSectionsProps) {
const secondaryAvg = nationalAvg?.secondary ?? {};
@@ -141,6 +144,15 @@ export function SecondarySchoolSections({
{flags.hasFinance && finance && (
<FinancesSection finance={finance} showPremises />
)}
{/* Last: it is where the reader goes next, not part of this school. */}
<SimilarSchoolsSection
urn={schoolInfo.urn}
schoolName={schoolInfo.school_name}
phaseNoun="secondary"
thisMetricValue={flags.latestResults?.attainment_8_score ?? null}
similar={similarSchools}
/>
</div>
);
}
+16 -2
View File
@@ -128,6 +128,10 @@ export interface NavItemsInput {
* measure a postcode, so the nav must gate on them too or it will link to an
* anchor that was never rendered. */
hasLocation?: boolean;
/** Whether the similar-schools section will render. Optional for the same
* reason hasLocation is: the nav must never link to an anchor that was not
* rendered, and absent has to mean "no section". */
hasSimilarSchools?: boolean;
yearlyDataLength: number;
}
@@ -142,7 +146,10 @@ export interface NavItemsInput {
*/
export function buildNavItems(
flags: SchoolFlags,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
{
ofsted, admissions, admissionDistance, hasLocation,
hasSimilarSchools, yearlyDataLength,
}: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -161,6 +168,8 @@ export function buildNavItems(
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (flags.hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
// Last, because the section renders last — the scroll-spy reads this order.
if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' });
return navItems;
}
@@ -239,7 +248,10 @@ export function computeSecondaryFlags({
*/
export function buildSecondaryNavItems(
flags: SecondaryFlags,
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
{
ofsted, admissions, admissionDistance, hasLocation,
hasSimilarSchools, yearlyDataLength,
}: NavItemsInput,
): NavItem[] {
const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -257,5 +269,7 @@ export function buildSecondaryNavItems(
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
// Last, because the section renders last — the scroll-spy reads this order.
if (hasSimilarSchools) navItems.push({ id: 'similar', label: 'Similar schools' });
return navItems;
}