fix: order nearby schools by distance, not by how alike they are
Reported from staging: a Catholic primary showed six Catholic primaries, none of them close enough to be a real option, and omitted the community school down the road. Three causes, compounding. Ranking put tier before distance, so a faith match at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule — added so a cap of six would not drag in weak distant matches — filled the row from the best tier before it ever widened, which is what made every card Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city for a primary, whose catchments are routinely under a mile. The premise was backwards. For a parent, distance is a constraint and intake is a preference; a school beyond a primary catchment is not a weaker option, it is not an option. So distance now decides the order and nothing else does. The hard filters are untouched — they were always where the defensibility lived. Similarity survives as chips on the card: reported, so a reader applies their own weighting, rather than ranked, so we apply ours for them. Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity bound, not a target: ordering already handles density, so the cap only decides what happens where an area is sparse. A primary with nothing inside two miles now renders no section, which is the honest answer. Deleted: the tier system, the stopping rule, the tier-dependent lede, the `tier` field, the tier-3 fallback chip and its style. select_similar also stops taking is_secondary — it reads the phase from the subject's own row, so no caller can hand it one that disagrees with the data. The heading is now "Other schools nearby". The hard filters still guarantee a comparable set, but nothing ranks on likeness, so the heading no longer says it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
151cf4bc80
commit
5c0ccc693d
11 files changed
+343
-278
No files matched your search
@@ -128,10 +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
|
||||
/** Whether the nearby-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;
|
||||
hasNearbySchools?: boolean;
|
||||
yearlyDataLength: number;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function buildNavItems(
|
||||
flags: SchoolFlags,
|
||||
{
|
||||
ofsted, admissions, admissionDistance, hasLocation,
|
||||
hasSimilarSchools, yearlyDataLength,
|
||||
hasNearbySchools, yearlyDataLength,
|
||||
}: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
@@ -169,7 +169,7 @@ export function buildNavItems(
|
||||
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' });
|
||||
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||||
return navItems;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function buildSecondaryNavItems(
|
||||
flags: SecondaryFlags,
|
||||
{
|
||||
ofsted, admissions, admissionDistance, hasLocation,
|
||||
hasSimilarSchools, yearlyDataLength,
|
||||
hasNearbySchools, yearlyDataLength,
|
||||
}: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
@@ -270,6 +270,6 @@ export function buildSecondaryNavItems(
|
||||
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' });
|
||||
if (hasNearbySchools) navItems.push({ id: 'nearby', label: 'Nearby schools' });
|
||||
return navItems;
|
||||
}
|
||||
@@ -347,12 +347,12 @@ export interface SchoolsResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* A nearby school of the same phase and a comparable intake.
|
||||
* A nearby school, from the nearest-first set the detail page shows.
|
||||
*
|
||||
* `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.
|
||||
* `shared` is what this school genuinely has in common with the one being
|
||||
* viewed, and may be empty. It is reported, never ranked on: an earlier
|
||||
* version ordered by it and buried the school down the road under faith
|
||||
* matches three times further away.
|
||||
*/
|
||||
export interface SimilarSchool {
|
||||
urn: number;
|
||||
@@ -361,7 +361,6 @@ export interface SimilarSchool {
|
||||
school_type: string | null;
|
||||
age_range: string | null;
|
||||
shared: string[];
|
||||
tier: number;
|
||||
metric_value: number | null;
|
||||
metric_key: string;
|
||||
metric_year: number | null;
|
||||
@@ -379,7 +378,7 @@ export interface SchoolDetailsResponse {
|
||||
*/
|
||||
places?: SchoolPlace[];
|
||||
/**
|
||||
* Up to six nearby schools of a comparable intake, nearest first.
|
||||
* Up to six nearby eligible schools, 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
|
||||
|
||||
Reference in new issue
Block a user