diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css index 3955163..49e518e 100644 --- a/nextjs-app/components/HomeView.module.css +++ b/nextjs-app/components/HomeView.module.css @@ -311,64 +311,24 @@ line-height: 1.5; } -.resultsHeader { - margin-bottom: 1rem; - padding-bottom: 0.625rem; - border-bottom: 2px solid var(--border-color, #e5dfd5); -} - -.resultsHeader h2 { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary, #1a1612); - margin: 0; +.schoolList { display: flex; - align-items: center; - gap: 0.375rem; -} - -.resultsHeader h2::before { - content: ''; - display: inline-block; - width: 3px; - height: 1.125em; - background: var(--accent-coral, #e07256); - border-radius: 2px; -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; + flex-direction: column; + gap: 0.5rem; margin-bottom: 1.25rem; } -/* Staggered grid entry animation */ -.grid > * { - animation: gridItemFadeIn 0.4s ease-out both; -} - -.grid > *:nth-child(1) { animation-delay: 0ms; } -.grid > *:nth-child(2) { animation-delay: 50ms; } -.grid > *:nth-child(3) { animation-delay: 100ms; } -.grid > *:nth-child(4) { animation-delay: 150ms; } -.grid > *:nth-child(5) { animation-delay: 200ms; } -.grid > *:nth-child(6) { animation-delay: 250ms; } -.grid > *:nth-child(7) { animation-delay: 300ms; } -.grid > *:nth-child(8) { animation-delay: 350ms; } -.grid > *:nth-child(9) { animation-delay: 400ms; } -.grid > *:nth-child(n+10) { animation-delay: 450ms; } - -@keyframes gridItemFadeIn { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} +/* Staggered fade-in for rows */ +.schoolList > *:nth-child(1) { animation-delay: 0ms; } +.schoolList > *:nth-child(2) { animation-delay: 30ms; } +.schoolList > *:nth-child(3) { animation-delay: 60ms; } +.schoolList > *:nth-child(4) { animation-delay: 90ms; } +.schoolList > *:nth-child(5) { animation-delay: 120ms; } +.schoolList > *:nth-child(6) { animation-delay: 150ms; } +.schoolList > *:nth-child(7) { animation-delay: 180ms; } +.schoolList > *:nth-child(8) { animation-delay: 210ms; } +.schoolList > *:nth-child(9) { animation-delay: 240ms; } +.schoolList > *:nth-child(n+10) { animation-delay: 270ms; } .emptyState { text-align: center; @@ -394,11 +354,6 @@ } @media (max-width: 768px) { - .grid { - grid-template-columns: 1fr; - gap: 0.75rem; - } - .locationBannerWrapper { flex-direction: column; align-items: stretch; diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index b65b01c..869eec6 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -8,7 +8,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; import { FilterBar } from './FilterBar'; -import { SchoolCard } from './SchoolCard'; +import { SchoolRow } from './SchoolRow'; import { SchoolMap } from './SchoolMap'; import { Pagination } from './Pagination'; import { EmptyState } from './EmptyState'; @@ -134,8 +134,8 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp @@ -204,11 +204,12 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp ) : ( /* List View Layout */ <> -
+
{sortedSchools.map((school) => ( - s.urn === school.urn)} diff --git a/nextjs-app/components/SchoolRow.module.css b/nextjs-app/components/SchoolRow.module.css new file mode 100644 index 0000000..2b880dd --- /dev/null +++ b/nextjs-app/components/SchoolRow.module.css @@ -0,0 +1,264 @@ +.row { + display: flex; + background: var(--bg-card, white); + border: 1px solid var(--border-color, #e5dfd5); + border-left: 3px solid transparent; + border-radius: 8px; + padding: 0.875rem 1rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + animation: rowFadeIn 0.3s ease-out both; +} + +.row:hover { + border-left-color: var(--accent-coral, #e07256); + box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06); +} + +.rowInCompare { + border-left-color: var(--accent-teal, #2d7d7d); + background: var(--bg-secondary, #f3ede4); +} + +@keyframes rowFadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.rowMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +/* Line 1 */ +.rowTop { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.schoolName { + flex: 1; + min-width: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary, #1a1612); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.schoolName:hover { + color: var(--accent-coral, #e07256); +} + +/* Score block: number + trend + label stacked */ +.scoreBlock { + display: flex; + flex-direction: column; + align-items: flex-end; + flex-shrink: 0; + gap: 0; + min-width: 60px; + text-align: right; +} + +.scoreValue { + font-size: 1.0625rem; + font-weight: 700; + color: var(--text-primary, #1a1612); + font-family: var(--font-playfair), 'Playfair Display', serif; + line-height: 1.2; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.scoreNA { + font-size: 1rem; + color: var(--text-muted, #8a847a); +} + +.scoreLabel { + font-size: 0.6875rem; + color: var(--text-muted, #8a847a); + white-space: nowrap; +} + +/* Trend arrow */ +.trend { + display: inline-flex; + align-items: center; + margin-left: 2px; +} + +.trendUp { + color: var(--accent-teal, #2d7d7d); +} + +.trendDown { + color: var(--accent-coral, #e07256); +} + +.trendStable { + color: var(--text-muted, #8a847a); +} + +/* Action buttons */ +.rowActions { + display: flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; +} + +.btnView { + padding: 0.3125rem 0.625rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #5c564d); + border: 1px solid var(--border-color, #e5dfd5); + border-radius: 5px; + text-decoration: none; + transition: all 0.15s ease; + white-space: nowrap; +} + +.btnView:hover { + background: var(--bg-secondary, #f3ede4); + color: var(--text-primary, #1a1612); + border-color: var(--text-muted, #8a847a); +} + +.btnCompare { + padding: 0.3125rem 0.625rem; + font-size: 0.8125rem; + font-weight: 600; + color: white; + background: var(--accent-coral, #e07256); + border: none; + border-radius: 5px; + cursor: pointer; + transition: background 0.15s ease; + white-space: nowrap; +} + +.btnCompare:hover { + background: var(--accent-coral-dark, #c45a3f); +} + +.btnRemove { + padding: 0.3125rem 0.625rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #5c564d); + background: var(--bg-secondary, #f3ede4); + border: 1px solid var(--border-color, #e5dfd5); + border-radius: 5px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.btnRemove:hover { + background: var(--border-color, #e5dfd5); + color: var(--text-primary, #1a1612); +} + +/* Line 2: Meta tags */ +.rowMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0; + font-size: 0.8rem; + color: var(--text-muted, #8a847a); +} + +.rowMeta span:not(:last-child)::after { + content: '·'; + margin: 0 0.4rem; + color: var(--border-color, #e5dfd5); +} + +.distanceBadge { + display: inline-block; + padding: 0.0625rem 0.375rem; + font-size: 0.75rem; + font-weight: 600; + background: var(--accent-teal, #2d7d7d); + color: white; + border-radius: 3px; + margin-left: 0.25rem; +} + +/* Line 3: Progress scores */ +.rowProgress { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-top: 0.125rem; +} + +.progressItem { + font-size: 0.8rem; + color: var(--text-secondary, #5c564d); +} + +.progressValue { + color: var(--text-primary, #1a1612); + margin: 0 0.25rem; +} + +.progressBand { + font-style: normal; + font-size: 0.75rem; + color: var(--text-muted, #8a847a); +} + +/* Mobile */ +@media (max-width: 640px) { + .row { + padding: 0.75rem; + border-radius: 6px; + } + + .rowTop { + flex-wrap: wrap; + gap: 0.5rem; + } + + .schoolName { + flex-basis: 100%; + white-space: normal; + font-size: 0.9375rem; + } + + .scoreBlock { + flex-direction: row; + align-items: center; + gap: 0.375rem; + flex-basis: auto; + } + + .scoreLabel { + display: none; + } + + .rowActions { + margin-left: auto; + } + + .rowProgress { + gap: 0.375rem 0.75rem; + } +} diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx new file mode 100644 index 0000000..30f528a --- /dev/null +++ b/nextjs-app/components/SchoolRow.tsx @@ -0,0 +1,157 @@ +/** + * SchoolRow Component + * Compact row-based school display for search results list view + */ + +import type { School } from '@/lib/types'; +import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils'; +import { progressBand } from '@/lib/metrics'; +import styles from './SchoolRow.module.css'; + +interface SchoolRowProps { + school: School; + isLocationSearch?: boolean; + isInCompare?: boolean; + onAddToCompare?: (school: School) => void; + onRemoveFromCompare?: (urn: number) => void; +} + +export function SchoolRow({ + school, + isLocationSearch, + isInCompare = false, + onAddToCompare, + onRemoveFromCompare, +}: SchoolRowProps) { + const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct); + + const hasProgress = + school.reading_progress != null || + school.writing_progress != null || + school.maths_progress != null; + + const handleCompareClick = () => { + if (isInCompare) { + onRemoveFromCompare?.(school.urn); + } else { + onAddToCompare?.(school); + } + }; + + return ( +
+
+ {/* Line 1: Name + score + actions */} +
+ + {school.school_name} + + +
+ {school.rwm_expected_pct != null ? ( + <> + + {formatPercentage(school.rwm_expected_pct, 0)} + + {school.prev_rwm_expected_pct != null && ( + + {trend === 'up' && ( + + + + )} + {trend === 'down' && ( + + + + )} + {trend === 'stable' && ( + + + + )} + + )} + + ) : ( + + )} + R, W & M +
+ +
+ + View + + {(onAddToCompare || onRemoveFromCompare) && ( + + )} +
+
+ + {/* Line 2: Meta tags */} +
+ {school.local_authority && {school.local_authority}} + {school.school_type && {school.school_type}} + {!isLocationSearch && + school.religious_denomination && + school.religious_denomination !== 'Does not apply' && ( + {school.religious_denomination} + )} + {isLocationSearch && school.distance != null && ( + + {(school.distance / 1609.34).toFixed(1)} mi + + )} +
+ + {/* Line 3: Progress scores with plain-language bands */} + {hasProgress && ( +
+ {school.reading_progress != null && ( + + Reading{' '} + + {formatProgress(school.reading_progress)} + + + {progressBand(school.reading_progress)} + + + )} + {school.writing_progress != null && ( + + Writing{' '} + + {formatProgress(school.writing_progress)} + + + {progressBand(school.writing_progress)} + + + )} + {school.maths_progress != null && ( + + Maths{' '} + + {formatProgress(school.maths_progress)} + + + {progressBand(school.maths_progress)} + + + )} +
+ )} +
+
+ ); +} diff --git a/nextjs-app/lib/metrics.ts b/nextjs-app/lib/metrics.ts new file mode 100644 index 0000000..e679a45 --- /dev/null +++ b/nextjs-app/lib/metrics.ts @@ -0,0 +1,113 @@ +/** + * Plain-language metric labels and explanations. + * Single source of truth for all metric display strings. + */ + +export interface MetricExplanation { + label: string; + plain: string; + detail?: string; +} + +export const METRIC_EXPLANATIONS: Record = { + rwm_expected_pct: { + label: 'Reading, Writing & Maths', + plain: '% of pupils achieving the expected standard in all three subjects at age 11', + detail: 'The national average is around 60%. Higher means more pupils reached the expected level.', + }, + rwm_high_pct: { + label: 'Higher Standard — Reading, Writing & Maths', + plain: '% of pupils exceeding the expected level in all three subjects', + detail: 'A more demanding threshold. The national average is around 8%.', + }, + reading_expected_pct: { + label: 'Reading — Expected Standard', + plain: '% of pupils achieving the expected standard in reading at age 11', + }, + writing_expected_pct: { + label: 'Writing — Expected Standard', + plain: '% of pupils achieving the expected standard in writing at age 11', + }, + maths_expected_pct: { + label: 'Maths — Expected Standard', + plain: '% of pupils achieving the expected standard in maths at age 11', + }, + reading_high_pct: { + label: 'Reading — Higher Standard', + plain: '% of pupils exceeding the expected level in reading', + }, + writing_high_pct: { + label: 'Writing — Higher Standard', + plain: '% of pupils exceeding the expected level in writing', + }, + maths_high_pct: { + label: 'Maths — Higher Standard', + plain: '% of pupils exceeding the expected level in maths', + }, + gps_expected_pct: { + label: 'Grammar & Spelling', + plain: '% of pupils achieving the expected standard in grammar, punctuation & spelling', + }, + science_expected_pct: { + label: 'Science', + plain: '% of pupils achieving the expected standard in science', + }, + reading_progress: { + label: 'Reading Progress', + plain: 'How much pupils improved in reading compared to similar schools', + detail: '0 = national average. Positive means better-than-average progress from Year 2 to Year 6.', + }, + writing_progress: { + label: 'Writing Progress', + plain: 'How much pupils improved in writing compared to similar schools', + detail: '0 = national average. Positive means better-than-average progress from Year 2 to Year 6.', + }, + maths_progress: { + label: 'Maths Progress', + plain: 'How much pupils improved in maths compared to similar schools', + detail: '0 = national average. Positive means better-than-average progress from Year 2 to Year 6.', + }, + reading_avg_score: { + label: 'Reading Average Score', + plain: 'Average scaled score in the reading test (range 80–120, average = 100)', + }, + maths_avg_score: { + label: 'Maths Average Score', + plain: 'Average scaled score in the maths test (range 80–120, average = 100)', + }, + gps_avg_score: { + label: 'Grammar & Spelling Average Score', + plain: 'Average scaled score in the grammar, punctuation & spelling test', + }, + overall_absence_pct: { + label: 'Absence Rate', + plain: '% of school sessions missed due to any reason', + detail: 'Lower is better. The national average is around 5%.', + }, + persistent_absence_pct: { + label: 'Persistent Absence', + plain: '% of pupils missing 10% or more of school sessions', + detail: 'Lower is better. Persistent absence can significantly affect attainment.', + }, + rwm_expected_disadvantaged_pct: { + label: 'Disadvantaged Pupils — Reading, Writing & Maths', + plain: '% of disadvantaged pupils achieving the expected standard in all three subjects', + }, + disadvantaged_gap: { + label: 'Disadvantaged Gap', + plain: 'Difference in attainment between disadvantaged pupils and their peers', + detail: 'A smaller gap means the school is doing more to support disadvantaged pupils.', + }, +}; + +/** + * Returns a plain-English band label for a KS2 progress score. + * Progress scores are centred at 0 (= national average). + */ +export function progressBand(score: number): string { + if (score > 3) return 'well above average'; + if (score > 1) return 'above average'; + if (score >= -1) return 'around average'; + if (score >= -3) return 'below average'; + return 'well below average'; +}