Revised Metrics Page
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>spaceward-frontend</title>
|
||||
<title>Spaceward</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
public/rocket.png
Normal file
BIN
public/rocket.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -1,6 +1,7 @@
|
||||
.metrics-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
@@ -8,11 +9,12 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 2rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metrics-header h2 {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(to right, #a78bfa, #ec4899);
|
||||
-webkit-background-clip: text;
|
||||
@@ -23,7 +25,7 @@
|
||||
|
||||
.metrics-subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
font-size: clamp(0.75rem, 2vw, 0.875rem);
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
@@ -40,6 +42,7 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-toggle-btn:hover {
|
||||
@@ -50,7 +53,7 @@
|
||||
background-color: #2b2b2b;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -64,21 +67,56 @@
|
||||
|
||||
.filter-section h3 {
|
||||
color: #d8b4fe;
|
||||
font-size: 1rem;
|
||||
font-size: clamp(0.875rem, 2.5vw, 1rem);
|
||||
font-weight: 600;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-action-btn {
|
||||
@@ -91,6 +129,7 @@
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-action-btn:hover {
|
||||
@@ -112,27 +151,38 @@
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.filter-checkbox:hover {
|
||||
background-color: rgba(124, 58, 237, 0.1);
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.filter-checkbox input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-checkbox span {
|
||||
.class-name-label {
|
||||
color: #e0e0e0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.period-label {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.metrics-loading,
|
||||
@@ -145,6 +195,7 @@
|
||||
padding: 4rem 2rem;
|
||||
color: #9ca3af;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metrics-hint {
|
||||
@@ -189,8 +240,8 @@
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(550px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@@ -199,6 +250,8 @@
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
@@ -208,18 +261,50 @@
|
||||
|
||||
.metric-card-header {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-class-name {
|
||||
color: #d8b4fe;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
font-size: clamp(1rem, 2.5vw, 1.125rem);
|
||||
margin-bottom: 0.25rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metric-class-info {
|
||||
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 {
|
||||
@@ -227,59 +312,42 @@
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem 0.5rem;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.nw-summary {
|
||||
.grade-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #374151;
|
||||
}
|
||||
|
||||
.nw-stat {
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nw-stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nw-name {
|
||||
.stat-label {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nw-stat-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.nw-grade {
|
||||
color: #4ade80;
|
||||
font-size: 1.125rem;
|
||||
.stat-value {
|
||||
color: #e0e0e0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nw-trend {
|
||||
.trend-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
@@ -294,21 +362,15 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.nw-updates {
|
||||
color: #6b7280;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.metrics-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
@@ -316,15 +378,71 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.filter-options-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nw-summary {
|
||||
grid-template-columns: 1fr;
|
||||
.filter-section-header {
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { api } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -10,7 +10,7 @@ export const Metrics = () => {
|
||||
const [finalGrades, setFinalGrades] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedPeriods, setSelectedPeriods] = useState(['NW1', 'NW2', 'NW3', 'NW4']);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('NW1');
|
||||
const [selectedClasses, setSelectedClasses] = useState([]);
|
||||
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
|
||||
const groupGradesByClass = () => {
|
||||
const grouped = {};
|
||||
|
||||
// Only consider NW periods, ignore SE/S1/S2/FIN
|
||||
const validCategories = ['NW1', 'NW2', 'NW3', 'NW4'];
|
||||
|
||||
finalGrades.forEach(grade => {
|
||||
const category = grade.class.category;
|
||||
|
||||
// Skip non-NW categories
|
||||
if (!validCategories.includes(category)) return;
|
||||
|
||||
const classKey = `${grade.class.className}-${grade.class.period}`;
|
||||
@@ -56,6 +60,7 @@ export const Metrics = () => {
|
||||
grouped[classKey] = {
|
||||
className: grade.class.className,
|
||||
period: grade.class.period,
|
||||
periodNumber: extractPeriodNumber(grade.class.period),
|
||||
teacher: grade.class.teacher,
|
||||
classId: grade.classId,
|
||||
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.keys(classData.nineWeeks).forEach(nw => {
|
||||
classData.nineWeeks[nw].sort((a, b) => a.timestamp - b.timestamp);
|
||||
@@ -84,63 +89,42 @@ export const Metrics = () => {
|
||||
return grouped;
|
||||
};
|
||||
|
||||
// Filter classes based on selection
|
||||
const filterClasses = (grouped) => {
|
||||
// Filter and sort classes
|
||||
const filterAndSortClasses = (grouped) => {
|
||||
const filtered = {};
|
||||
|
||||
Object.entries(grouped).forEach(([key, classData]) => {
|
||||
if (!selectedClasses.includes(key)) return;
|
||||
|
||||
// Filter nine-weeks periods
|
||||
const filteredNW = {};
|
||||
selectedPeriods.forEach(period => {
|
||||
if (classData.nineWeeks[period] && classData.nineWeeks[period].length > 0) {
|
||||
filteredNW[period] = classData.nineWeeks[period];
|
||||
// Only include if the selected period has data
|
||||
if (classData.nineWeeks[selectedPeriod] && classData.nineWeeks[selectedPeriod].length > 0) {
|
||||
filtered[key] = classData;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(filteredNW).length > 0) {
|
||||
filtered[key] = {
|
||||
...classData,
|
||||
nineWeeks: filteredNW
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Prepare chart data
|
||||
const prepareChartData = (nineWeeks) => {
|
||||
const maxLength = Math.max(
|
||||
...Object.values(nineWeeks).map(arr => arr.length)
|
||||
// Sort by period number
|
||||
const sortedEntries = Object.entries(filtered).sort((a, b) =>
|
||||
a[1].periodNumber - b[1].periodNumber
|
||||
);
|
||||
|
||||
const chartData = [];
|
||||
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;
|
||||
return Object.fromEntries(sortedEntries);
|
||||
};
|
||||
|
||||
// Calculate Y-axis domain based on actual data
|
||||
const calculateYDomain = (nineWeeks) => {
|
||||
const allGrades = Object.values(nineWeeks)
|
||||
.flat()
|
||||
.map(g => g.grade)
|
||||
.filter(g => g !== null && g !== undefined);
|
||||
// Prepare chart data for a single nine-weeks period
|
||||
const prepareChartData = (gradeArray) => {
|
||||
return gradeArray.map((item, index) => ({
|
||||
updateNumber: index + 1,
|
||||
grade: item.grade
|
||||
}));
|
||||
};
|
||||
|
||||
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 max = Math.max(...allGrades);
|
||||
const grades = gradeArray.map(g => g.grade);
|
||||
const min = Math.min(...grades);
|
||||
const max = Math.max(...grades);
|
||||
const padding = (max - min) * 0.1 || 5;
|
||||
|
||||
return [
|
||||
@@ -187,14 +171,6 @@ export const Metrics = () => {
|
||||
NW4: '4th Nine Weeks'
|
||||
};
|
||||
|
||||
const togglePeriod = (period) => {
|
||||
setSelectedPeriods(prev =>
|
||||
prev.includes(period)
|
||||
? prev.filter(p => p !== period)
|
||||
: [...prev, period]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleClass = (classKey) => {
|
||||
setSelectedClasses(prev =>
|
||||
prev.includes(classKey)
|
||||
@@ -231,15 +207,17 @@ export const Metrics = () => {
|
||||
}
|
||||
|
||||
const groupedGrades = groupGradesByClass();
|
||||
const filteredGrades = filterClasses(groupedGrades);
|
||||
const allClasses = Object.keys(groupedGrades);
|
||||
const filteredGrades = filterAndSortClasses(groupedGrades);
|
||||
const allClasses = Object.keys(groupedGrades).sort((a, b) =>
|
||||
groupedGrades[a].periodNumber - groupedGrades[b].periodNumber
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="metrics-container">
|
||||
<div className="metrics-header">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<button
|
||||
@@ -254,17 +232,19 @@ export const Metrics = () => {
|
||||
{showFilters && (
|
||||
<div className="filters-panel">
|
||||
<div className="filter-section">
|
||||
<h3>Nine-Weeks Periods</h3>
|
||||
<div className="filter-options">
|
||||
<h3>Nine-Weeks Period</h3>
|
||||
<div className="period-selector">
|
||||
{['NW1', 'NW2', 'NW3', 'NW4'].map(period => (
|
||||
<label key={period} className="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPeriods.includes(period)}
|
||||
onChange={() => togglePeriod(period)}
|
||||
/>
|
||||
<span style={{ color: COLORS[period] }}>{PERIOD_NAMES[period]}</span>
|
||||
</label>
|
||||
<button
|
||||
key={period}
|
||||
className={`period-btn ${selectedPeriod === period ? 'active' : ''}`}
|
||||
style={{
|
||||
'--period-color': COLORS[period]
|
||||
}}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
>
|
||||
{PERIOD_NAMES[period]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +267,8 @@ export const Metrics = () => {
|
||||
checked={selectedClasses.includes(classKey)}
|
||||
onChange={() => toggleClass(classKey)}
|
||||
/>
|
||||
<span>{classData.className}</span>
|
||||
<span className="class-name-label">{classData.className}</span>
|
||||
<span className="period-label">{classData.period}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -299,14 +280,17 @@ export const Metrics = () => {
|
||||
{Object.keys(filteredGrades).length === 0 ? (
|
||||
<div className="metrics-empty">
|
||||
<div className="placeholder-icon">📊</div>
|
||||
<p>No grade data available for the selected filters</p>
|
||||
<p className="metrics-hint">Try selecting different periods or classes</p>
|
||||
<p>No grade data available for {PERIOD_NAMES[selectedPeriod]}</p>
|
||||
<p className="metrics-hint">Try selecting a different period or classes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="metrics-grid">
|
||||
{Object.entries(filteredGrades).map(([key, classData]) => {
|
||||
const chartData = prepareChartData(classData.nineWeeks);
|
||||
const yDomain = calculateYDomain(classData.nineWeeks);
|
||||
const gradeData = classData.nineWeeks[selectedPeriod];
|
||||
const chartData = prepareChartData(gradeData);
|
||||
const yDomain = calculateYDomain(gradeData);
|
||||
const latest = getLatestGrade(gradeData);
|
||||
const trend = calculateTrend(gradeData);
|
||||
|
||||
return (
|
||||
<div key={key} className="metric-card">
|
||||
@@ -317,10 +301,14 @@ export const Metrics = () => {
|
||||
{classData.period} • {classData.teacher}
|
||||
</p>
|
||||
</div>
|
||||
<div className="current-grade-badge">
|
||||
<span className="badge-label">Current</span>
|
||||
<span className="badge-value">{latest}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
@@ -344,61 +332,41 @@ export const Metrics = () => {
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
labelStyle={{ color: '#d8b4fe' }}
|
||||
formatter={(value, name) => [
|
||||
value ? `${value.toFixed(2)}%` : 'N/A',
|
||||
PERIOD_NAMES[name]
|
||||
]}
|
||||
formatter={(value) => [`${value.toFixed(2)}%`, 'Grade']}
|
||||
labelFormatter={(label) => `Update #${label}`}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
formatter={(value) => PERIOD_NAMES[value]}
|
||||
/>
|
||||
{Object.keys(classData.nineWeeks).map(nw => (
|
||||
<Line
|
||||
key={nw}
|
||||
type="monotone"
|
||||
dataKey={nw}
|
||||
stroke={COLORS[nw]}
|
||||
strokeWidth={2.5}
|
||||
dot={{ r: 4, fill: COLORS[nw] }}
|
||||
activeDot={{ r: 6 }}
|
||||
connectNulls
|
||||
dataKey="grade"
|
||||
stroke={COLORS[selectedPeriod]}
|
||||
strokeWidth={3}
|
||||
dot={{ r: 5, fill: COLORS[selectedPeriod], strokeWidth: 2, stroke: '#1e1e1e' }}
|
||||
activeDot={{ r: 7 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="nw-summary">
|
||||
{Object.entries(classData.nineWeeks).map(([nw, grades]) => {
|
||||
const latest = getLatestGrade(grades);
|
||||
const trend = calculateTrend(grades);
|
||||
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">
|
||||
<div className="grade-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Trend</span>
|
||||
<div className="stat-value trend-value">
|
||||
{getTrendIcon(trend.trend)}
|
||||
<span className={`trend-${trend.trend}`}>
|
||||
{Math.abs(trend.change).toFixed(2)}%
|
||||
{trend.change > 0 ? '+' : ''}{trend.change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className="nw-updates">({grades.length} updates)</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user