Revised Metrics Page

This commit is contained in:
2025-12-22 20:28:20 -06:00
parent 74ca58a91a
commit 3300cebcdc
4 changed files with 269 additions and 183 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" href="/rocket.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>spaceward-frontend</title> <title>Spaceward</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/rocket.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -1,6 +1,7 @@
.metrics-container { .metrics-container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 1rem;
} }
.metrics-header { .metrics-header {
@@ -8,11 +9,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: start; align-items: start;
gap: 2rem; gap: 1rem;
flex-wrap: wrap;
} }
.metrics-header h2 { .metrics-header h2 {
font-size: 2rem; font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 700; font-weight: 700;
background: linear-gradient(to right, #a78bfa, #ec4899); background: linear-gradient(to right, #a78bfa, #ec4899);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -23,7 +25,7 @@
.metrics-subtitle { .metrics-subtitle {
color: #9ca3af; color: #9ca3af;
font-size: 0.875rem; font-size: clamp(0.75rem, 2vw, 0.875rem);
} }
.filter-toggle-btn { .filter-toggle-btn {
@@ -40,6 +42,7 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.filter-toggle-btn:hover { .filter-toggle-btn:hover {
@@ -50,7 +53,7 @@
background-color: #2b2b2b; background-color: #2b2b2b;
border: 1px solid #374151; border: 1px solid #374151;
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.5rem; padding: 1.25rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -64,21 +67,56 @@
.filter-section h3 { .filter-section h3 {
color: #d8b4fe; color: #d8b4fe;
font-size: 1rem; font-size: clamp(0.875rem, 2.5vw, 1rem);
font-weight: 600; font-weight: 600;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.period-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.period-btn {
background-color: #1e1e1e;
border: 2px solid #374151;
color: #e0e0e0;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.period-btn:hover {
border-color: var(--period-color);
background-color: rgba(124, 58, 237, 0.1);
}
.period-btn.active {
border-color: var(--period-color);
background-color: var(--period-color);
color: white;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.3);
}
.filter-section-header { .filter-section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
gap: 1rem;
flex-wrap: wrap;
} }
.filter-actions { .filter-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.filter-action-btn { .filter-action-btn {
@@ -91,6 +129,7 @@
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap;
} }
.filter-action-btn:hover { .filter-action-btn:hover {
@@ -112,27 +151,38 @@
.filter-checkbox { .filter-checkbox {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
padding: 0.5rem; padding: 0.625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
background-color: #1e1e1e;
border: 1px solid #374151;
} }
.filter-checkbox:hover { .filter-checkbox:hover {
background-color: rgba(124, 58, 237, 0.1); background-color: rgba(124, 58, 237, 0.1);
border-color: #7c3aed;
} }
.filter-checkbox input[type="checkbox"] { .filter-checkbox input[type="checkbox"] {
cursor: pointer; cursor: pointer;
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: 0.5rem;
} }
.filter-checkbox span { .class-name-label {
color: #e0e0e0; color: #e0e0e0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600;
}
.period-label {
color: #9ca3af;
font-size: 0.75rem;
margin-left: 1.5rem;
} }
.metrics-loading, .metrics-loading,
@@ -145,6 +195,7 @@
padding: 4rem 2rem; padding: 4rem 2rem;
color: #9ca3af; color: #9ca3af;
gap: 1rem; gap: 1rem;
text-align: center;
} }
.metrics-hint { .metrics-hint {
@@ -189,8 +240,8 @@
.metrics-grid { .metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(550px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr));
gap: 1.5rem; gap: 1.25rem;
} }
.metric-card { .metric-card {
@@ -199,6 +250,8 @@
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.25rem; padding: 1.25rem;
transition: all 0.2s; transition: all 0.2s;
display: flex;
flex-direction: column;
} }
.metric-card:hover { .metric-card:hover {
@@ -208,18 +261,50 @@
.metric-card-header { .metric-card-header {
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
} }
.metric-class-name { .metric-class-name {
color: #d8b4fe; color: #d8b4fe;
font-weight: 600; font-weight: 600;
font-size: 1.125rem; font-size: clamp(1rem, 2.5vw, 1.125rem);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
word-break: break-word;
} }
.metric-class-info { .metric-class-info {
color: #9ca3af; color: #9ca3af;
font-size: 0.75rem; font-size: clamp(0.7rem, 2vw, 0.75rem);
word-break: break-word;
}
.current-grade-badge {
display: flex;
flex-direction: column;
align-items: flex-end;
background-color: #1e1e1e;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid #374151;
min-width: 70px;
}
.badge-label {
color: #9ca3af;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-value {
color: #4ade80;
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
} }
.chart-container { .chart-container {
@@ -227,59 +312,42 @@
background-color: #1e1e1e; background-color: #1e1e1e;
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
min-height: 240px;
} }
.nw-summary { .grade-stats {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid #374151; border-top: 1px solid #374151;
} }
.nw-stat { .stat-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
} }
.nw-stat-header { .stat-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.nw-name {
color: #9ca3af; color: #9ca3af;
font-size: 0.75rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.nw-stat-data { .stat-value {
display: flex; color: #e0e0e0;
align-items: center; font-size: 1rem;
gap: 0.75rem;
}
.nw-grade {
color: #4ade80;
font-size: 1.125rem;
font-weight: 700; font-weight: 700;
} }
.nw-trend { .trend-value {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
} }
.trend-up { .trend-up {
@@ -294,21 +362,15 @@
color: #9ca3af; color: #9ca3af;
} }
.nw-updates { /* Responsive adjustments */
color: #6b7280;
font-size: 0.7rem;
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.metrics-container {
padding: 0.75rem;
}
.metrics-header { .metrics-header {
flex-direction: column; flex-direction: column;
gap: 1rem; align-items: stretch;
} }
.filter-toggle-btn { .filter-toggle-btn {
@@ -316,15 +378,71 @@
justify-content: center; justify-content: center;
} }
.filters-panel {
padding: 1rem;
}
.period-selector {
grid-template-columns: repeat(2, 1fr);
}
.filter-options-grid { .filter-options-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.nw-summary { .filter-section-header {
grid-template-columns: 1fr; flex-direction: column;
align-items: stretch;
}
.filter-actions {
justify-content: stretch;
}
.filter-action-btn {
flex: 1;
}
.metric-card {
padding: 1rem;
}
.metric-card-header {
flex-direction: column;
}
.current-grade-badge {
align-self: flex-start;
align-items: flex-start;
} }
.chart-container { .chart-container {
padding: 0.5rem 0.25rem; padding: 0.5rem 0.25rem;
margin: 0.75rem 0;
}
.grade-stats {
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
}
@media (max-width: 480px) {
.period-selector {
grid-template-columns: 1fr;
}
.grade-stats {
grid-template-columns: 1fr;
}
.metrics-grid {
gap: 1rem;
}
}
@media (min-width: 1400px) {
.metrics-grid {
grid-template-columns: repeat(auto-fill, minmax(450px, 1fr));
} }
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { TrendingUp, TrendingDown, Minus, Filter } from 'lucide-react'; import { TrendingUp, TrendingDown, Minus, Filter } from 'lucide-react';
import { api } from '../services/api'; import { api } from '../services/api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -10,7 +10,7 @@ export const Metrics = () => {
const [finalGrades, setFinalGrades] = useState([]); const [finalGrades, setFinalGrades] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(['NW1', 'NW2', 'NW3', 'NW4']); const [selectedPeriod, setSelectedPeriod] = useState('NW1');
const [selectedClasses, setSelectedClasses] = useState([]); const [selectedClasses, setSelectedClasses] = useState([]);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
@@ -37,17 +37,21 @@ export const Metrics = () => {
} }
}; };
// Extract period number from period string like "Period 5 - Year"
const extractPeriodNumber = (periodStr) => {
const match = periodStr.match(/Period\s+(\d+)/i);
return match ? parseInt(match[1], 10) : 999; // 999 for sorting unknown periods to end
};
// Group grades by class and organize by nine-weeks periods // Group grades by class and organize by nine-weeks periods
const groupGradesByClass = () => { const groupGradesByClass = () => {
const grouped = {}; const grouped = {};
// Only consider NW periods, ignore SE/S1/S2/FIN
const validCategories = ['NW1', 'NW2', 'NW3', 'NW4']; const validCategories = ['NW1', 'NW2', 'NW3', 'NW4'];
finalGrades.forEach(grade => { finalGrades.forEach(grade => {
const category = grade.class.category; const category = grade.class.category;
// Skip non-NW categories
if (!validCategories.includes(category)) return; if (!validCategories.includes(category)) return;
const classKey = `${grade.class.className}-${grade.class.period}`; const classKey = `${grade.class.className}-${grade.class.period}`;
@@ -56,6 +60,7 @@ export const Metrics = () => {
grouped[classKey] = { grouped[classKey] = {
className: grade.class.className, className: grade.class.className,
period: grade.class.period, period: grade.class.period,
periodNumber: extractPeriodNumber(grade.class.period),
teacher: grade.class.teacher, teacher: grade.class.teacher,
classId: grade.classId, classId: grade.classId,
nineWeeks: { nineWeeks: {
@@ -74,7 +79,7 @@ export const Metrics = () => {
}); });
}); });
// Sort each nine-weeks period by timestamp (chronological) // Sort each nine-weeks period by timestamp
Object.values(grouped).forEach(classData => { Object.values(grouped).forEach(classData => {
Object.keys(classData.nineWeeks).forEach(nw => { Object.keys(classData.nineWeeks).forEach(nw => {
classData.nineWeeks[nw].sort((a, b) => a.timestamp - b.timestamp); classData.nineWeeks[nw].sort((a, b) => a.timestamp - b.timestamp);
@@ -84,63 +89,42 @@ export const Metrics = () => {
return grouped; return grouped;
}; };
// Filter classes based on selection // Filter and sort classes
const filterClasses = (grouped) => { const filterAndSortClasses = (grouped) => {
const filtered = {}; const filtered = {};
Object.entries(grouped).forEach(([key, classData]) => { Object.entries(grouped).forEach(([key, classData]) => {
if (!selectedClasses.includes(key)) return; if (!selectedClasses.includes(key)) return;
// Filter nine-weeks periods // Only include if the selected period has data
const filteredNW = {}; if (classData.nineWeeks[selectedPeriod] && classData.nineWeeks[selectedPeriod].length > 0) {
selectedPeriods.forEach(period => { filtered[key] = classData;
if (classData.nineWeeks[period] && classData.nineWeeks[period].length > 0) {
filteredNW[period] = classData.nineWeeks[period];
} }
}); });
if (Object.keys(filteredNW).length > 0) { // Sort by period number
filtered[key] = { const sortedEntries = Object.entries(filtered).sort((a, b) =>
...classData, a[1].periodNumber - b[1].periodNumber
nineWeeks: filteredNW
};
}
});
return filtered;
};
// Prepare chart data
const prepareChartData = (nineWeeks) => {
const maxLength = Math.max(
...Object.values(nineWeeks).map(arr => arr.length)
); );
const chartData = []; return Object.fromEntries(sortedEntries);
for (let i = 0; i < maxLength; i++) {
const dataPoint = { updateNumber: i + 1 };
Object.keys(nineWeeks).forEach(nw => {
dataPoint[nw] = nineWeeks[nw][i]?.grade || null;
});
chartData.push(dataPoint);
}
return chartData;
}; };
// Calculate Y-axis domain based on actual data // Prepare chart data for a single nine-weeks period
const calculateYDomain = (nineWeeks) => { const prepareChartData = (gradeArray) => {
const allGrades = Object.values(nineWeeks) return gradeArray.map((item, index) => ({
.flat() updateNumber: index + 1,
.map(g => g.grade) grade: item.grade
.filter(g => g !== null && g !== undefined); }));
};
if (allGrades.length === 0) return [0, 100]; // Calculate Y-axis domain
const calculateYDomain = (gradeArray) => {
if (gradeArray.length === 0) return [0, 100];
const min = Math.min(...allGrades); const grades = gradeArray.map(g => g.grade);
const max = Math.max(...allGrades); const min = Math.min(...grades);
const max = Math.max(...grades);
const padding = (max - min) * 0.1 || 5; const padding = (max - min) * 0.1 || 5;
return [ return [
@@ -187,14 +171,6 @@ export const Metrics = () => {
NW4: '4th Nine Weeks' NW4: '4th Nine Weeks'
}; };
const togglePeriod = (period) => {
setSelectedPeriods(prev =>
prev.includes(period)
? prev.filter(p => p !== period)
: [...prev, period]
);
};
const toggleClass = (classKey) => { const toggleClass = (classKey) => {
setSelectedClasses(prev => setSelectedClasses(prev =>
prev.includes(classKey) prev.includes(classKey)
@@ -231,15 +207,17 @@ export const Metrics = () => {
} }
const groupedGrades = groupGradesByClass(); const groupedGrades = groupGradesByClass();
const filteredGrades = filterClasses(groupedGrades); const filteredGrades = filterAndSortClasses(groupedGrades);
const allClasses = Object.keys(groupedGrades); const allClasses = Object.keys(groupedGrades).sort((a, b) =>
groupedGrades[a].periodNumber - groupedGrades[b].periodNumber
);
return ( return (
<div className="metrics-container"> <div className="metrics-container">
<div className="metrics-header"> <div className="metrics-header">
<div> <div>
<h2>Grade Metrics</h2> <h2>Grade Metrics</h2>
<p className="metrics-subtitle">Track your nine-weeks grade trends over time</p> <p className="metrics-subtitle">Track your grade trends over time</p>
</div> </div>
<button <button
@@ -254,17 +232,19 @@ export const Metrics = () => {
{showFilters && ( {showFilters && (
<div className="filters-panel"> <div className="filters-panel">
<div className="filter-section"> <div className="filter-section">
<h3>Nine-Weeks Periods</h3> <h3>Nine-Weeks Period</h3>
<div className="filter-options"> <div className="period-selector">
{['NW1', 'NW2', 'NW3', 'NW4'].map(period => ( {['NW1', 'NW2', 'NW3', 'NW4'].map(period => (
<label key={period} className="filter-checkbox"> <button
<input key={period}
type="checkbox" className={`period-btn ${selectedPeriod === period ? 'active' : ''}`}
checked={selectedPeriods.includes(period)} style={{
onChange={() => togglePeriod(period)} '--period-color': COLORS[period]
/> }}
<span style={{ color: COLORS[period] }}>{PERIOD_NAMES[period]}</span> onClick={() => setSelectedPeriod(period)}
</label> >
{PERIOD_NAMES[period]}
</button>
))} ))}
</div> </div>
</div> </div>
@@ -287,7 +267,8 @@ export const Metrics = () => {
checked={selectedClasses.includes(classKey)} checked={selectedClasses.includes(classKey)}
onChange={() => toggleClass(classKey)} onChange={() => toggleClass(classKey)}
/> />
<span>{classData.className}</span> <span className="class-name-label">{classData.className}</span>
<span className="period-label">{classData.period}</span>
</label> </label>
); );
})} })}
@@ -299,14 +280,17 @@ export const Metrics = () => {
{Object.keys(filteredGrades).length === 0 ? ( {Object.keys(filteredGrades).length === 0 ? (
<div className="metrics-empty"> <div className="metrics-empty">
<div className="placeholder-icon">📊</div> <div className="placeholder-icon">📊</div>
<p>No grade data available for the selected filters</p> <p>No grade data available for {PERIOD_NAMES[selectedPeriod]}</p>
<p className="metrics-hint">Try selecting different periods or classes</p> <p className="metrics-hint">Try selecting a different period or classes</p>
</div> </div>
) : ( ) : (
<div className="metrics-grid"> <div className="metrics-grid">
{Object.entries(filteredGrades).map(([key, classData]) => { {Object.entries(filteredGrades).map(([key, classData]) => {
const chartData = prepareChartData(classData.nineWeeks); const gradeData = classData.nineWeeks[selectedPeriod];
const yDomain = calculateYDomain(classData.nineWeeks); const chartData = prepareChartData(gradeData);
const yDomain = calculateYDomain(gradeData);
const latest = getLatestGrade(gradeData);
const trend = calculateTrend(gradeData);
return ( return (
<div key={key} className="metric-card"> <div key={key} className="metric-card">
@@ -317,10 +301,14 @@ export const Metrics = () => {
{classData.period} {classData.teacher} {classData.period} {classData.teacher}
</p> </p>
</div> </div>
<div className="current-grade-badge">
<span className="badge-label">Current</span>
<span className="badge-value">{latest}%</span>
</div>
</div> </div>
<div className="chart-container"> <div className="chart-container">
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" /> <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis <XAxis
@@ -344,61 +332,41 @@ export const Metrics = () => {
fontSize: '0.75rem' fontSize: '0.75rem'
}} }}
labelStyle={{ color: '#d8b4fe' }} labelStyle={{ color: '#d8b4fe' }}
formatter={(value, name) => [ formatter={(value) => [`${value.toFixed(2)}%`, 'Grade']}
value ? `${value.toFixed(2)}%` : 'N/A',
PERIOD_NAMES[name]
]}
labelFormatter={(label) => `Update #${label}`} labelFormatter={(label) => `Update #${label}`}
/> />
<Legend
wrapperStyle={{
fontFamily: 'JetBrains Mono',
fontSize: '11px'
}}
formatter={(value) => PERIOD_NAMES[value]}
/>
{Object.keys(classData.nineWeeks).map(nw => (
<Line <Line
key={nw}
type="monotone" type="monotone"
dataKey={nw} dataKey="grade"
stroke={COLORS[nw]} stroke={COLORS[selectedPeriod]}
strokeWidth={2.5} strokeWidth={3}
dot={{ r: 4, fill: COLORS[nw] }} dot={{ r: 5, fill: COLORS[selectedPeriod], strokeWidth: 2, stroke: '#1e1e1e' }}
activeDot={{ r: 6 }} activeDot={{ r: 7 }}
connectNulls
/> />
))}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="nw-summary"> <div className="grade-stats">
{Object.entries(classData.nineWeeks).map(([nw, grades]) => { <div className="stat-item">
const latest = getLatestGrade(grades); <span className="stat-label">Trend</span>
const trend = calculateTrend(grades); <div className="stat-value trend-value">
return (
<div key={nw} className="nw-stat">
<div className="nw-stat-header">
<span
className="category-dot"
style={{ backgroundColor: COLORS[nw] }}
></span>
<span className="nw-name">{PERIOD_NAMES[nw]}</span>
</div>
<div className="nw-stat-data">
<span className="nw-grade">{latest}%</span>
<div className="nw-trend">
{getTrendIcon(trend.trend)} {getTrendIcon(trend.trend)}
<span className={`trend-${trend.trend}`}> <span className={`trend-${trend.trend}`}>
{Math.abs(trend.change).toFixed(2)}% {trend.change > 0 ? '+' : ''}{trend.change.toFixed(2)}%
</span> </span>
</div> </div>
<span className="nw-updates">({grades.length} updates)</span>
</div> </div>
<div className="stat-item">
<span className="stat-label">Updates</span>
<span className="stat-value">{gradeData.length}</span>
</div>
<div className="stat-item">
<span className="stat-label">Period</span>
<span className="stat-value" style={{ color: COLORS[selectedPeriod] }}>
{PERIOD_NAMES[selectedPeriod]}
</span>
</div> </div>
);
})}
</div> </div>
</div> </div>
); );