Revised Metrics Page
This commit is contained in:
@@ -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
BIN
public/rocket.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
filtered[key] = {
|
|
||||||
...classData,
|
|
||||||
nineWeeks: filteredNW
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
// Sort by period number
|
||||||
};
|
const sortedEntries = Object.entries(filtered).sort((a, b) =>
|
||||||
|
a[1].periodNumber - b[1].periodNumber
|
||||||
// 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);
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate Y-axis domain
|
||||||
|
const calculateYDomain = (gradeArray) => {
|
||||||
|
if (gradeArray.length === 0) return [0, 100];
|
||||||
|
|
||||||
if (allGrades.length === 0) return [0, 100];
|
const grades = gradeArray.map(g => g.grade);
|
||||||
|
const min = Math.min(...grades);
|
||||||
const min = Math.min(...allGrades);
|
const max = Math.max(...grades);
|
||||||
const max = Math.max(...allGrades);
|
|
||||||
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
|
<Line
|
||||||
wrapperStyle={{
|
type="monotone"
|
||||||
fontFamily: 'JetBrains Mono',
|
dataKey="grade"
|
||||||
fontSize: '11px'
|
stroke={COLORS[selectedPeriod]}
|
||||||
}}
|
strokeWidth={3}
|
||||||
formatter={(value) => PERIOD_NAMES[value]}
|
dot={{ r: 5, fill: COLORS[selectedPeriod], strokeWidth: 2, stroke: '#1e1e1e' }}
|
||||||
|
activeDot={{ r: 7 }}
|
||||||
/>
|
/>
|
||||||
{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
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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 (
|
{getTrendIcon(trend.trend)}
|
||||||
<div key={nw} className="nw-stat">
|
<span className={`trend-${trend.trend}`}>
|
||||||
<div className="nw-stat-header">
|
{trend.change > 0 ? '+' : ''}{trend.change.toFixed(2)}%
|
||||||
<span
|
</span>
|
||||||
className="category-dot"
|
</div>
|
||||||
style={{ backgroundColor: COLORS[nw] }}
|
</div>
|
||||||
></span>
|
<div className="stat-item">
|
||||||
<span className="nw-name">{PERIOD_NAMES[nw]}</span>
|
<span className="stat-label">Updates</span>
|
||||||
</div>
|
<span className="stat-value">{gradeData.length}</span>
|
||||||
<div className="nw-stat-data">
|
</div>
|
||||||
<span className="nw-grade">{latest}%</span>
|
<div className="stat-item">
|
||||||
<div className="nw-trend">
|
<span className="stat-label">Period</span>
|
||||||
{getTrendIcon(trend.trend)}
|
<span className="stat-value" style={{ color: COLORS[selectedPeriod] }}>
|
||||||
<span className={`trend-${trend.trend}`}>
|
{PERIOD_NAMES[selectedPeriod]}
|
||||||
{Math.abs(trend.change).toFixed(2)}%
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
<span className="nw-updates">({grades.length} updates)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user