feat(secondary): apply hero design language to secondary school detail view
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
- Bump school name to clamp(2rem,5vw,3.25rem) Playfair Display - Add hero signal chips strip (framework-aware Ofsted + coral Oversubscribed) - Add at-a-glance stats row: Att8 with delta vs national, Ofsted serif tile, first-choice rate - Active section highlighting in sticky nav via IntersectionObserver - Collapse OEIF Ofsted section to prose when all sub-grades match overall - Pass nationalAtt8Avg reference line to PerformanceChart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,11 +26,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 1.5rem;
|
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.2;
|
line-height: 1.15;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,17 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabBtnActive {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBtnActive:hover {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card ────────────────────────────────────────────── */
|
/* ── Card ────────────────────────────────────────────── */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
@@ -652,6 +663,145 @@
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted all-same collapse ────────────────────────── */
|
||||||
|
.ofstedAllSame {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedAllSame strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero chips strip ────────────────────────────────── */
|
||||||
|
.heroChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip {
|
||||||
|
flex: 0 0 240px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--border-color, #e5dfd5);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipTitle {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipSub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipDetail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero tone system */
|
||||||
|
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
|
||||||
|
.tone-green { --hero-tone: #3c8c3c; }
|
||||||
|
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
|
||||||
|
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
|
||||||
|
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
|
||||||
|
|
||||||
|
.heroChip.tone-teal,
|
||||||
|
.heroChip.tone-green,
|
||||||
|
.heroChip.tone-gold,
|
||||||
|
.heroChip.tone-coral,
|
||||||
|
.heroChip.tone-neutral {
|
||||||
|
border-left-color: var(--hero-tone);
|
||||||
|
background: color-mix(in srgb, var(--hero-tone) 10%, var(--bg-card, white));
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip.tone-neutral {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero at-a-glance stats ──────────────────────────── */
|
||||||
|
.heroStats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem 3rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumber,
|
||||||
|
.heroStatNumberSerif {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
min-height: clamp(2rem, 4vw, 2.75rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumber {
|
||||||
|
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumberSerif {
|
||||||
|
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumberSerif.tone-teal,
|
||||||
|
.heroStatNumberSerif.tone-green,
|
||||||
|
.heroStatNumberSerif.tone-gold,
|
||||||
|
.heroStatNumberSerif.tone-coral,
|
||||||
|
.heroStatNumberSerif.tone-neutral {
|
||||||
|
color: var(--hero-tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatFoot {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroDataNote {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
@@ -673,7 +823,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 1.25rem;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +882,19 @@
|
|||||||
.admissionsTypeBadge {
|
.admissionsTypeBadge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroChips {
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStats {
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type {
|
|||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
||||||
|
import { DeltaChip } from './DeltaChip';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -75,6 +76,8 @@ export function SecondarySchoolDetailView({
|
|||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
const isInComparison = isSelected(schoolInfo.urn);
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
@@ -124,6 +127,50 @@ export function SecondarySchoolDetailView({
|
|||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
|
||||||
|
|
||||||
|
// Track active section as user scrolls
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = navItems.map(n => n.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
const ratioMap: Record<string, number> = {};
|
||||||
|
const pickActive = () => {
|
||||||
|
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||||
|
};
|
||||||
|
ids.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
ratioMap[id] = 0;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => { ratioMap[id] = entry.intersectionRatio; pickActive(); },
|
||||||
|
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
observers.push(obs);
|
||||||
|
});
|
||||||
|
return () => observers.forEach(o => o.disconnect());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
|
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||||
|
const oeifAllSameGrade = (() => {
|
||||||
|
if (!ofsted || ofsted.framework === 'ReportCard') return false;
|
||||||
|
const subs = [
|
||||||
|
ofsted.quality_of_education,
|
||||||
|
ofsted.behaviour_attitudes,
|
||||||
|
ofsted.personal_development,
|
||||||
|
ofsted.leadership_management,
|
||||||
|
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
||||||
|
].filter((v): v is number => v != null);
|
||||||
|
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Hero signal chip & stats ─────────────────────────────────────────
|
||||||
|
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
|
||||||
|
const heroAtt8 = latestResults?.attainment_8_score ?? null;
|
||||||
|
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||||
|
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* ── Header ─────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────── */}
|
||||||
@@ -190,6 +237,75 @@ export function SecondarySchoolDetailView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hero signal chips */}
|
||||||
|
<div className={styles.heroChips}>
|
||||||
|
<div className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||||||
|
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
||||||
|
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
||||||
|
{ofstedHeroChip.detail && (
|
||||||
|
<div className={styles.heroChipDetail}>{ofstedHeroChip.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{admissions?.oversubscribed && (
|
||||||
|
<div className={`${styles.heroChip} ${styles['tone-coral']}`}>
|
||||||
|
<div className={styles.heroChipTitle}>Oversubscribed</div>
|
||||||
|
<div className={styles.heroChipSub}>
|
||||||
|
{admissions.first_preference_offer_pct != null
|
||||||
|
? `${Math.round(admissions.first_preference_offer_pct)}% of first-choice applicants offered a place`
|
||||||
|
: 'More applicants than places'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* At-a-glance stats row */}
|
||||||
|
{latestResults && (
|
||||||
|
<div className={styles.heroStats}>
|
||||||
|
{heroAtt8 != null && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
|
||||||
|
<div className={styles.heroStatLabel}>Attainment 8 score</div>
|
||||||
|
{heroAtt8Nat != null && (
|
||||||
|
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ofsted && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={`${styles.heroStatNumberSerif} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
||||||
|
{ofstedHeroChip.state === 'oeif'
|
||||||
|
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
|
||||||
|
: ofstedHeroChip.state === 'reportCard'
|
||||||
|
? 'Report Card'
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatLabel}>{ofstedHeroChip.subtitle}</div>
|
||||||
|
{ofstedHeroChip.detail && (
|
||||||
|
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admissions?.first_preference_offer_pct != null && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={styles.heroStatNumber}>
|
||||||
|
{Math.round(admissions.first_preference_offer_pct)}%
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatLabel}>First-choice offer rate</div>
|
||||||
|
{admissions.oversubscribed && (
|
||||||
|
<div className={styles.heroStatFoot}>Oversubscribed</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{heroAcademicYear && (
|
||||||
|
<p className={styles.heroDataNote}>Latest data: {heroAcademicYear}</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Sticky section navigation ─────────────────────── */}
|
{/* ── Sticky section navigation ─────────────────────── */}
|
||||||
@@ -198,7 +314,13 @@ export function SecondarySchoolDetailView({
|
|||||||
<button onClick={() => router.back()} className={styles.backBtn}>← Back</button>
|
<button onClick={() => router.back()} className={styles.backBtn}>← Back</button>
|
||||||
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
{navItems.length > 0 && <div className={styles.tabNavDivider} />}
|
||||||
{navItems.map(({ id, label }) => (
|
{navItems.map(({ id, label }) => (
|
||||||
<a key={id} href={`#${id}`} className={styles.tabBtn}>{label}</a>
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -265,6 +387,11 @@ export function SecondarySchoolDetailView({
|
|||||||
<p className={styles.ofstedDisclaimer}>
|
<p className={styles.ofstedDisclaimer}>
|
||||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
||||||
</p>
|
</p>
|
||||||
|
{oeifAllSameGrade ? (
|
||||||
|
<p className={styles.ofstedAllSame}>
|
||||||
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{[
|
{[
|
||||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
||||||
@@ -283,6 +410,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -489,6 +617,7 @@ export function SecondarySchoolDetailView({
|
|||||||
data={yearlyData}
|
data={yearlyData}
|
||||||
schoolName={schoolInfo.school_name}
|
schoolName={schoolInfo.school_name}
|
||||||
isSecondary={true}
|
isSecondary={true}
|
||||||
|
nationalAtt8Avg={heroAtt8Nat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user