feat: similar schools nearby on the detail page #150
No files matched your search
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* The section's job is to be honest about what it matched. These tests pin the
|
||||
* ways it could lie: rendering below the minimum, claiming a similar intake at
|
||||
* tier 3, showing a missing figure as a number, or hiding a card behind an
|
||||
* arrow where a crawler cannot reach it.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
SimilarSchoolsSection,
|
||||
shouldRenderSimilar,
|
||||
} from '@/components/school/SimilarSchoolsSection';
|
||||
import type { SimilarSchool } from '@/lib/types';
|
||||
|
||||
jest.mock('@/components/school/AddToCompareButton', () => ({
|
||||
AddToCompareButton: ({ school }: { school: SimilarSchool }) => (
|
||||
<button type="button">Add {school.school_name} to compare</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
|
||||
SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
|
||||
<div data-testid="compare-bar">bar for {thisUrn}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function school(overrides: Partial<SimilarSchool> = {}): SimilarSchool {
|
||||
return {
|
||||
urn: 100002,
|
||||
school_name: 'Willow Lane Primary School',
|
||||
distance_miles: 0.6,
|
||||
school_type: 'Community school',
|
||||
age_range: '4-11',
|
||||
shared: ['Mixed', 'No religious character'],
|
||||
tier: 1,
|
||||
metric_value: 74,
|
||||
metric_key: 'rwm_expected_pct',
|
||||
metric_year: 202425,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(similar: SimilarSchool[]) {
|
||||
return render(
|
||||
<SimilarSchoolsSection
|
||||
urn={100001}
|
||||
schoolName="Meadowbrook Primary School"
|
||||
phaseNoun="primary"
|
||||
thisMetricValue={72}
|
||||
similar={similar}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('render gates', () => {
|
||||
it.each([
|
||||
['undefined', undefined],
|
||||
['null', null],
|
||||
['empty', []],
|
||||
['a single school', [school()]],
|
||||
])('renders nothing for %s', (_label, value) => {
|
||||
expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('renders for two or more schools', () => {
|
||||
expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null rather than an empty shell below the minimum', () => {
|
||||
const { container } = renderSection([school()]);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the claim the lede makes', () => {
|
||||
it('claims a similar intake when every card is tier 1 or 2', () => {
|
||||
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]);
|
||||
expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the claim when any card is tier 3', () => {
|
||||
renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]);
|
||||
expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cards', () => {
|
||||
it('links each school to its canonical slug', () => {
|
||||
renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
|
||||
const link = screen.getByRole('link', { name: /Willow Lane Primary School/ });
|
||||
expect(link).toHaveAttribute('href', '/school/100002-willow-lane-primary-school');
|
||||
});
|
||||
|
||||
it('shows the distance and the shared characteristics', () => {
|
||||
renderSection([school(), school({ urn: 100003 })]);
|
||||
expect(screen.getAllByText('0.6 miles away').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Mixed').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders a missing figure as "Not published", never as a number', () => {
|
||||
renderSection([school({ metric_value: null }), school({ urn: 100003 })]);
|
||||
expect(screen.getByText('Not published')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('anchors each figure against this school', () => {
|
||||
renderSection([school(), school({ urn: 100003 })]);
|
||||
expect(screen.getAllByText('72% at this school').length).toBe(2);
|
||||
});
|
||||
|
||||
it('offers the compare bar once, for this school', () => {
|
||||
renderSection([school(), school({ urn: 100003 })]);
|
||||
expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001');
|
||||
});
|
||||
|
||||
it('keeps every card in the DOM, including the ones scrolled out of view', () => {
|
||||
const six = Array.from({ length: 6 }, (_, n) =>
|
||||
school({ urn: 100002 + n, school_name: `Peer ${n} School` }),
|
||||
);
|
||||
renderSection(six);
|
||||
expect(screen.getAllByRole('link', { name: /Peer \d School/ })).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('offers no arrows when three cards fit the row', () => {
|
||||
renderSection([school(), school({ urn: 100003 }), school({ urn: 100004 })]);
|
||||
expect(screen.queryByRole('button', { name: /More schools/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers arrows once there is a fourth school', () => {
|
||||
renderSection(Array.from({ length: 4 }, (_, n) => school({ urn: 100002 + n })));
|
||||
expect(screen.getByRole('button', { name: /More schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Previous schools/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says distances are straight-line, and offers no method panel', () => {
|
||||
const { container } = renderSection([school(), school({ urn: 100003 })]);
|
||||
expect(screen.getByText(/straight-line from this school/i)).toBeInTheDocument();
|
||||
expect(container.querySelector('details')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The per-card basket toggle.
|
||||
*
|
||||
* The links around it are server-rendered, so the section works with JS off;
|
||||
* this adds the basket interaction on top rather than being what makes the
|
||||
* section function.
|
||||
*/
|
||||
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import type { School, SimilarSchool } from '@/lib/types';
|
||||
import styles from './SimilarSchools.module.css';
|
||||
|
||||
export function AddToCompareButton({ school }: { school: SimilarSchool }) {
|
||||
const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
|
||||
const selected = selectedSchools.some((s) => s.urn === school.urn);
|
||||
|
||||
const toggle = () => {
|
||||
if (selected) {
|
||||
removeSchool(school.urn);
|
||||
return;
|
||||
}
|
||||
// The basket only needs identity and display fields; the compare page
|
||||
// fetches everything it renders by URN.
|
||||
addSchool({
|
||||
urn: school.urn,
|
||||
school_name: school.school_name,
|
||||
school_type: school.school_type,
|
||||
age_range: school.age_range,
|
||||
} as School);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.add}
|
||||
onClick={toggle}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{selected ? '✓ Added to compare' : '+ Add to compare'}
|
||||
<span className={styles.srOnly}> — {school.school_name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; }
|
||||
.lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; }
|
||||
.caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); }
|
||||
|
||||
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.arrows { display: flex; gap: 0.5rem; flex: none; }
|
||||
.arrow { width: 44px; height: 44px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); }
|
||||
.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); }
|
||||
.arrow:disabled { opacity: 0.35; cursor: default; }
|
||||
.arrow svg { width: 17px; height: 17px; }
|
||||
|
||||
/* A scroller, not a paginated view: every card is in the DOM and the arrows
|
||||
only move the viewport across them. The 2px padding keeps focus rings from
|
||||
being clipped — and is why the arrows' edge test needs a tolerance. */
|
||||
.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.scroller::-webkit-scrollbar { display: none; }
|
||||
@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } }
|
||||
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and
|
||||
crush the lede into four lines, for a control swiping already provides. They
|
||||
go, and the documented right-edge fade carries the affordance — lifting at
|
||||
the end of the travel, where there is nothing more to hint at. */
|
||||
@media (max-width: 640px) {
|
||||
.top { display: block; }
|
||||
.arrows { display: none; }
|
||||
.scroller { grid-auto-columns: 86%; mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); }
|
||||
.scroller[data-at-end="true"] { mask-image: none; }
|
||||
}
|
||||
|
||||
.school { position: relative; display: flex; flex-direction: column; scroll-snap-align: start; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); }
|
||||
.school:hover { border-color: var(--border-strong); }
|
||||
|
||||
.distance { margin: 0 0 0.6rem; font-size: 0.75rem; color: var(--text-muted); }
|
||||
.name { font-family: var(--font-display); font-size: 1rem; line-height: 1.35; margin: 0 0 0.35rem; }
|
||||
.name a { color: var(--text-primary); text-decoration: none; }
|
||||
/* The whole card is the link target; the button sits above it on z-index. */
|
||||
.name a::after { content: ""; position: absolute; inset: 0; border-radius: 8px; }
|
||||
.name a:hover { color: var(--brand); text-decoration: underline; }
|
||||
.meta { margin: 0 0 0.75rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||
|
||||
.shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; }
|
||||
.chip { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: var(--brand-bg); color: var(--brand); border: 1px solid transparent; }
|
||||
.chipLoose { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
||||
|
||||
.metric { margin-top: auto; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||||
/* No valence colour here, deliberately: green and terracotta mean "against the
|
||||
England average" everywhere else on the site, and colouring a neighbour
|
||||
against this school would read as ranking the neighbours. */
|
||||
.value { font-family: var(--font-display); font-size: 1.6rem; font-weight: 700; letter-spacing: -0.6px; margin: 0; color: var(--text-primary); }
|
||||
.valueAbsent { font-size: 0.95rem; font-weight: 600; margin: 0; color: var(--text-muted); }
|
||||
.metricLabel { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--text-secondary); }
|
||||
.metricRef { margin: 0.1rem 0 0; font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 44px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); }
|
||||
.add:hover { border-color: var(--brand); background: var(--brand-bg); }
|
||||
.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; }
|
||||
|
||||
.footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.85rem; margin-top: 1.25rem; padding-top: 1.1rem; border-top: 1px solid var(--border); }
|
||||
.footer p { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.footer strong { display: block; font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
|
||||
/* Coral is the one decisive action per screen, and in this section this is it. */
|
||||
.compare { min-height: 44px; padding: 0 1.25rem; border-radius: 8px; font-size: 0.88rem; font-weight: 600; background: var(--action); color: var(--action-on); border: 1px solid var(--action); text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.compare:hover { background: var(--action-strong); border-color: var(--action-strong); }
|
||||
.compare[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
|
||||
|
||||
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The scroller and its arrows.
|
||||
*
|
||||
* `children` are the server-rendered cards and `header` the server-rendered
|
||||
* heading and lede: both stay server components, passed through, so this file
|
||||
* owns a DOM ref and nothing else. That is what keeps all six links in the
|
||||
* initial HTML — a carousel that mounted cards on click would put four of the
|
||||
* six beyond a crawler and beyond a reader with no JavaScript.
|
||||
*
|
||||
* With JavaScript off this degrades to a horizontally scrollable row, which is
|
||||
* still usable by touch and trackpad.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import styles from './SimilarSchools.module.css';
|
||||
|
||||
/** Three cards fit the row, so fewer than four has nowhere to scroll to.
|
||||
* Below 640px the arrows are not rendered at all — see the stylesheet. */
|
||||
const VISIBLE = 3;
|
||||
|
||||
/**
|
||||
* Why a tolerance rather than `=== 0`.
|
||||
*
|
||||
* The scroller carries 2px of padding so focus rings are not clipped, and
|
||||
* scroll-snap treats that padding as the first card's snap position — a row at
|
||||
* rest reports scrollLeft 2, not 0. Sub-pixel rounding moves it again at other
|
||||
* zoom levels. An exact test leaves the back arrow live on first paint,
|
||||
* pointing nowhere.
|
||||
*/
|
||||
const EDGE = 8;
|
||||
|
||||
export function SimilarSchoolsCarousel({
|
||||
count,
|
||||
labelledBy,
|
||||
header,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
labelledBy: string;
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const scroller = useRef<HTMLUListElement>(null);
|
||||
const [atStart, setAtStart] = useState(true);
|
||||
const [atEnd, setAtEnd] = useState(false);
|
||||
const scrollable = count > VISIBLE;
|
||||
|
||||
const sync = useCallback(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
const max = node.scrollWidth - node.clientWidth;
|
||||
setAtStart(node.scrollLeft <= EDGE);
|
||||
setAtEnd(node.scrollLeft >= max - EDGE);
|
||||
}, []);
|
||||
// `atEnd` is not only the forward arrow's disabled state: below 640px, where
|
||||
// no arrow is rendered, it is the only thing driving the scroll-fade.
|
||||
|
||||
// Also on mount: the first measurement can only happen once there is layout.
|
||||
useEffect(sync, [sync]);
|
||||
|
||||
const page = (direction: 1 | -1) => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
// A page is what the reader can see, so the viewport is the step.
|
||||
node.scrollBy({ left: direction * node.clientWidth, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.top}>
|
||||
{header}
|
||||
{scrollable && (
|
||||
<div className={styles.arrows}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.arrow}
|
||||
onClick={() => page(-1)}
|
||||
disabled={atStart}
|
||||
aria-label="Previous schools"
|
||||
>
|
||||
<Chevron direction="prev" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.arrow}
|
||||
onClick={() => page(1)}
|
||||
disabled={atEnd}
|
||||
aria-label="More schools"
|
||||
>
|
||||
<Chevron direction="next" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul
|
||||
ref={scroller}
|
||||
className={styles.scroller}
|
||||
onScroll={sync}
|
||||
data-at-end={atEnd}
|
||||
{...(scrollable
|
||||
? { tabIndex: 0, role: 'group', 'aria-labelledby': labelledBy }
|
||||
: {})}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ direction }: { direction: 'prev' | 'next' }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={direction === 'prev' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The selection count and the one decisive action in the section.
|
||||
*
|
||||
* The CTA is a real link, not a handler: /compare already parses `urns` from
|
||||
* the query string, so the hand-off needs no new compare plumbing. It counts
|
||||
* this school plus whatever the reader ticked, because comparing a shortlist
|
||||
* without the school they are looking at is not what they asked for.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import type { SimilarSchool } from '@/lib/types';
|
||||
import styles from './SimilarSchools.module.css';
|
||||
|
||||
export function SimilarSchoolsCompareBar({
|
||||
thisUrn,
|
||||
candidates,
|
||||
}: {
|
||||
thisUrn: number;
|
||||
candidates: SimilarSchool[];
|
||||
}) {
|
||||
const { selectedSchools } = useComparisonContext();
|
||||
|
||||
// Only the schools this section offers, in the order the cards show them —
|
||||
// the basket may hold schools picked up elsewhere on the site, and this bar
|
||||
// speaks for this section.
|
||||
const offered = candidates
|
||||
.map((c) => c.urn)
|
||||
.filter((urn) => selectedSchools.some((s) => s.urn === urn));
|
||||
|
||||
const count = offered.length;
|
||||
const href = `/compare?urns=${[thisUrn, ...offered].join(',')}`;
|
||||
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
<p aria-live="polite">
|
||||
<strong>
|
||||
{count
|
||||
? `${count} school${count === 1 ? '' : 's'} selected`
|
||||
: 'Compare side by side'}
|
||||
</strong>
|
||||
{count
|
||||
? 'This school is included automatically.'
|
||||
: 'Add a school to compare it with this one.'}
|
||||
</p>
|
||||
{count ? (
|
||||
<Link className={styles.compare} href={href}>
|
||||
Compare {count + 1} schools →
|
||||
</Link>
|
||||
) : (
|
||||
<span className={styles.compare} aria-disabled="true">
|
||||
Compare →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* SimilarSchoolsSection — nearby schools of the same phase and a comparable
|
||||
* intake. Server component; only the carousel, the compare bar and the
|
||||
* add-to-compare button are client-side.
|
||||
*
|
||||
* The section is allowed to say exactly what the backend matched and no more.
|
||||
* The lede only claims a similar intake when no card came from tier 3, and a
|
||||
* card's chips list what that school actually shares rather than a match it
|
||||
* did not earn.
|
||||
*
|
||||
* There is deliberately no "how these are chosen" panel: the method is already
|
||||
* visible in the lede, the chips and the distances. The single caption line is
|
||||
* not a method note — it is the one thing a card cannot self-correct.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { SimilarSchool } from '@/lib/types';
|
||||
import { schoolUrl } from '@/lib/utils';
|
||||
import { AddToCompareButton } from './AddToCompareButton';
|
||||
import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel';
|
||||
import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar';
|
||||
import { Section } from './sectionShared';
|
||||
import styles from './SimilarSchools.module.css';
|
||||
|
||||
const MINIMUM = 2;
|
||||
|
||||
export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean {
|
||||
return (similar?.length ?? 0) >= MINIMUM;
|
||||
}
|
||||
|
||||
function metricLabel(key: string): string {
|
||||
return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
|
||||
}
|
||||
|
||||
function formatMetric(value: number | null, key: string): string {
|
||||
if (value == null) return 'Not published';
|
||||
return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
export function SimilarSchoolsSection({
|
||||
urn,
|
||||
schoolName,
|
||||
phaseNoun,
|
||||
thisMetricValue,
|
||||
similar,
|
||||
}: {
|
||||
urn: number;
|
||||
schoolName: string;
|
||||
phaseNoun: string;
|
||||
thisMetricValue: number | null;
|
||||
similar?: SimilarSchool[] | null;
|
||||
}) {
|
||||
if (!shouldRenderSimilar(similar)) return null;
|
||||
const schools = similar as SimilarSchool[];
|
||||
|
||||
// One card matched on phase alone, so the section may not claim the set
|
||||
// shares an intake with this school.
|
||||
const loosest = Math.max(...schools.map((s) => s.tier));
|
||||
const metricKey = schools[0].metric_key;
|
||||
|
||||
return (
|
||||
<Section id="similar">
|
||||
<SimilarSchoolsCarousel
|
||||
count={schools.length}
|
||||
labelledBy="similar-schools-heading"
|
||||
header={
|
||||
<div>
|
||||
<h2 id="similar-schools-heading" className={styles.heading}>
|
||||
Similar schools nearby
|
||||
</h2>
|
||||
<p className={styles.lede}>
|
||||
{loosest >= 3
|
||||
? `Other ${phaseNoun} schools near ${schoolName}.`
|
||||
: `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{schools.map((school) => (
|
||||
<li key={school.urn} className={styles.school}>
|
||||
<p className={styles.distance}>{school.distance_miles} miles away</p>
|
||||
<h3 className={styles.name}>
|
||||
<Link href={schoolUrl(school.urn, school.school_name)}>
|
||||
{school.school_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className={styles.meta}>
|
||||
{[school.school_type, school.age_range && `Ages ${school.age_range}`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<ul className={styles.shared}>
|
||||
{school.shared.map((label) => (
|
||||
<li key={label} className={school.tier >= 3 ? styles.chipLoose : styles.chip}>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.metric}>
|
||||
<p
|
||||
className={
|
||||
school.metric_value == null ? styles.valueAbsent : styles.value
|
||||
}
|
||||
>
|
||||
{formatMetric(school.metric_value, school.metric_key)}
|
||||
</p>
|
||||
<p className={styles.metricLabel}>{metricLabel(school.metric_key)}</p>
|
||||
{thisMetricValue != null && (
|
||||
<p className={styles.metricRef}>
|
||||
{formatMetric(thisMetricValue, metricKey)} at this school
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AddToCompareButton school={school} />
|
||||
</li>
|
||||
))}
|
||||
</SimilarSchoolsCarousel>
|
||||
|
||||
<SimilarSchoolsCompareBar thisUrn={urn} candidates={schools} />
|
||||
|
||||
{/* The one caveat the cards cannot make on their own: a reader who takes
|
||||
"0.6 miles away" for the walk has been misled, and nothing else here
|
||||
corrects that. */}
|
||||
<p className={styles.caption}>
|
||||
Distances are straight-line from this school, not road distance.
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -346,6 +346,27 @@ export interface SchoolsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A nearby school of the same phase and a comparable intake.
|
||||
*
|
||||
* `tier` is carried explicitly rather than inferred from `shared`, because it
|
||||
* drives two separate decisions — whether the lede may claim a similar intake,
|
||||
* and whether a chip renders as a fill or a muted outline — and inferring it
|
||||
* from chip count would couple those decisions to the copy.
|
||||
*/
|
||||
export interface SimilarSchool {
|
||||
urn: number;
|
||||
school_name: string;
|
||||
distance_miles: number;
|
||||
school_type: string | null;
|
||||
age_range: string | null;
|
||||
shared: string[];
|
||||
tier: number;
|
||||
metric_value: number | null;
|
||||
metric_key: string;
|
||||
metric_year: number | null;
|
||||
}
|
||||
|
||||
export interface SchoolDetailsResponse {
|
||||
school_info: School;
|
||||
/**
|
||||
@@ -357,6 +378,14 @@ export interface SchoolDetailsResponse {
|
||||
* authority both fall below the publish threshold has nowhere to link.
|
||||
*/
|
||||
places?: SchoolPlace[];
|
||||
/**
|
||||
* Up to six nearby schools of a comparable intake, nearest first.
|
||||
*
|
||||
* Optional for the same reason as `places`: a frontend deployed ahead of the
|
||||
* API that serves this must render without it. Absent and empty mean the
|
||||
* same thing here — no section.
|
||||
*/
|
||||
similar_schools?: SimilarSchool[];
|
||||
yearly_data: SchoolResult[];
|
||||
absence_data: AbsenceData | null;
|
||||
// Supplementary data (null until Kestra populates)
|
||||
|
||||
Reference in new issue
Block a user