PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m8s
Review follow-up on #86. The blind coral -> brand rename recreated the exact collision this PR set out to remove: coral had been both the primary CTA and the "below average" signal, so every negative indicator followed --primary onto iris. Sixteen rules moved back onto the status ramp — delta chips, trend-down arrows, progress-negative values, statusBad, chipBad/badgeBad, and the urgent deadline chips. The Ofsted scale had also lost its worst step, with grade 4 landing on brand while 1-2 were teal and 3 was amber. It now escalates by weight rather than by reaching for another hue: a tinted amber chip for "requires improvement", a solid amber one for "inadequate" (5.1:1 light, 7.7:1 dark). Report-card grade 5 follows the same rule. globals.css now describes status as valence — teal above/good, amber below/needs-attention — which is what it has to mean for an urgent deadline, rather than the narrower "comparison point only" the first draft claimed. On the share-card fonts: /opengraph-image is prerendered, so the font read happens in the builder stage where assets/ exists, and the baked PNG ships inside .next/standalone/.next/server/app/. File tracing independently places the fonts at .next/standalone/assets, which the existing standalone COPY carries to /app/assets. So the reported ENOENT doesn't occur — but it depends on the tracer resolving a runtime join(), and a miss would be a silent 500 rather than a build failure. Declared outputFileTracingIncludes for the route and made the Dockerfile COPY explicit so neither is left to inference. Also repointed the immutable Cache-Control rule from the deleted favicon.svg to app/icon.svg, where it was caching a 404. Verified: tsc clean, 159/159 tests, clean rebuild prerenders all three image routes with the fonts present in standalone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
9.2 KiB
TypeScript
228 lines
9.2 KiB
TypeScript
/**
|
||
* LeafletMapInner Component
|
||
* Internal Leaflet map implementation (client-side only)
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useEffect, useRef } from 'react';
|
||
import L from 'leaflet';
|
||
import 'leaflet/dist/leaflet.css';
|
||
import type { School } from '@/lib/types';
|
||
import { schoolUrl } from '@/lib/utils';
|
||
|
||
// Fix for default marker icons in Next.js
|
||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||
});
|
||
|
||
interface LeafletMapInnerProps {
|
||
schools: School[];
|
||
center: [number, number];
|
||
zoom: number;
|
||
referencePoint?: [number, number];
|
||
onMarkerClick?: (school: School) => void;
|
||
nationalAvgRwm?: number | null;
|
||
laAverages?: Record<string, number | null>;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
interface PopupBadge {
|
||
label: string;
|
||
style: string;
|
||
}
|
||
|
||
function buildPopupBadge(school: School): PopupBadge {
|
||
// A report card wins over any carried-forward legacy grade — its presence is
|
||
// signalled by ofsted_rc_date (the list has no full report_card object, and
|
||
// ofsted_framework is the raw event grouping, never "ReportCard").
|
||
if (school.ofsted_rc_date) {
|
||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
|
||
}
|
||
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
|
||
const yearStr = year ? ` · ${year}` : '';
|
||
if (school.ofsted_grade) {
|
||
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
||
const colours: Record<number, string> = {
|
||
1: 'background:var(--status-above-bg);color:var(--status-above)',
|
||
2: 'background:var(--status-above-bg);color:var(--status-above)',
|
||
3: 'background:var(--status-below-bg);color:var(--status-below)',
|
||
4: 'background:var(--status-below);color:var(--text-inverse)',
|
||
};
|
||
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
|
||
}
|
||
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
|
||
}
|
||
|
||
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
|
||
const mapRef = useRef<L.Map | null>(null);
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
const refMarkerRef = useRef<L.Marker | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!mapContainerRef.current) return;
|
||
|
||
// Initialize map
|
||
if (!mapRef.current) {
|
||
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
maxZoom: 19,
|
||
}).addTo(mapRef.current);
|
||
}
|
||
|
||
// Clear existing school markers (not the reference pin)
|
||
mapRef.current.eachLayer((layer) => {
|
||
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
||
mapRef.current!.removeLayer(layer);
|
||
}
|
||
});
|
||
|
||
// Add reference pin (search location)
|
||
if (refMarkerRef.current) {
|
||
refMarkerRef.current.remove();
|
||
refMarkerRef.current = null;
|
||
}
|
||
if (referencePoint && mapRef.current) {
|
||
const refIcon = L.divIcon({
|
||
html: `<div style="
|
||
width: 20px; height: 20px;
|
||
background: var(--brand);
|
||
border: 3px solid white;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||
"></div>`,
|
||
iconSize: [20, 20],
|
||
iconAnchor: [10, 10],
|
||
className: '',
|
||
});
|
||
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
||
.addTo(mapRef.current)
|
||
.bindPopup('<strong>Search location</strong>');
|
||
}
|
||
|
||
// Add markers for schools
|
||
schools.forEach((school) => {
|
||
if (school.latitude && school.longitude && mapRef.current) {
|
||
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
|
||
|
||
// Create popup content
|
||
const badge = buildPopupBadge(school);
|
||
const isSecondary = school.attainment_8_score != null;
|
||
|
||
// Phase label
|
||
const rawPhase = (school.phase ?? '').toLowerCase();
|
||
const phaseLabel =
|
||
rawPhase.includes('secondary') ? 'Secondary' :
|
||
rawPhase === 'all-through' ? 'All-through' :
|
||
rawPhase.includes('primary') ? 'Primary' :
|
||
isSecondary ? 'Secondary' : 'Primary';
|
||
|
||
// Distance string
|
||
const distanceStr =
|
||
school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : '';
|
||
|
||
// Headline metric
|
||
let metricHtml = '';
|
||
if (isSecondary) {
|
||
const score = school.attainment_8_score!;
|
||
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
|
||
let deltaLine = '';
|
||
if (laAvg != null) {
|
||
const diff = Math.round((score - laAvg) * 10) / 10;
|
||
const sign = diff >= 0 ? '+' : '';
|
||
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
||
// vs primary RWM % where ±2 pts is the threshold
|
||
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
|
||
const laName = escapeHtml(school.local_authority ?? 'LA');
|
||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
||
}
|
||
metricHtml = `<div style="margin-bottom:4px">
|
||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
|
||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
|
||
${deltaLine}
|
||
</div>`;
|
||
} else if (school.rwm_expected_pct != null) {
|
||
const rwm = school.rwm_expected_pct;
|
||
let deltaLine = '';
|
||
if (nationalAvgRwm != null) {
|
||
const diff = Math.round(rwm - nationalAvgRwm);
|
||
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
|
||
const text =
|
||
diff >= 2 ? `+${diff} pts vs national` :
|
||
diff <= -2 ? `${diff} pts vs national` :
|
||
'≈ national avg';
|
||
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
||
}
|
||
metricHtml = `<div style="margin-bottom:4px">
|
||
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
|
||
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing & Maths</span>
|
||
${deltaLine}
|
||
</div>`;
|
||
}
|
||
|
||
const slug = schoolUrl(school.urn, school.school_name);
|
||
|
||
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
||
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
|
||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||
</div>
|
||
${metricHtml}
|
||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||
</div>`;
|
||
|
||
marker.bindPopup(popupContent);
|
||
|
||
if (onMarkerClick) {
|
||
marker.on('click', () => onMarkerClick(school));
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update map view
|
||
if (schools.length > 1) {
|
||
const bounds = L.latLngBounds(
|
||
schools
|
||
.filter(s => s.latitude && s.longitude)
|
||
.map(s => [s.latitude!, s.longitude!] as [number, number])
|
||
);
|
||
mapRef.current.fitBounds(bounds, { padding: [50, 50] });
|
||
} else {
|
||
mapRef.current.setView(center, zoom);
|
||
}
|
||
|
||
// Cleanup
|
||
return () => {
|
||
// Don't destroy map on every update, just clean markers
|
||
};
|
||
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
||
|
||
// Cleanup map on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
if (mapRef.current) {
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
return <div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />;
|
||
}
|