diff --git a/index.html b/index.html index 60f5334..5b23688 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - spaceward-frontend + Spaceward
diff --git a/public/rocket.png b/public/rocket.png new file mode 100644 index 0000000..c410ecc Binary files /dev/null and b/public/rocket.png differ diff --git a/src/components/Metrics.css b/src/components/Metrics.css index 36fdc15..7f250d3 100644 --- a/src/components/Metrics.css +++ b/src/components/Metrics.css @@ -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)); } } diff --git a/src/components/Metrics.jsx b/src/components/Metrics.jsx index cba5863..ad981e6 100644 --- a/src/components/Metrics.jsx +++ b/src/components/Metrics.jsx @@ -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]; - } - }); - - if (Object.keys(filteredNW).length > 0) { - filtered[key] = { - ...classData, - nineWeeks: filteredNW - }; + // Only include if the selected period has data + if (classData.nineWeeks[selectedPeriod] && classData.nineWeeks[selectedPeriod].length > 0) { + filtered[key] = classData; } }); - 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 + })); + }; + + // Calculate Y-axis domain + const calculateYDomain = (gradeArray) => { + if (gradeArray.length === 0) return [0, 100]; - if (allGrades.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 (

Grade Metrics

-

Track your nine-weeks grade trends over time

+

Track your grade trends over time

))}
@@ -287,7 +267,8 @@ export const Metrics = () => { checked={selectedClasses.includes(classKey)} onChange={() => toggleClass(classKey)} /> - {classData.className} + {classData.className} + {classData.period} ); })} @@ -299,14 +280,17 @@ export const Metrics = () => { {Object.keys(filteredGrades).length === 0 ? (
📊
-

No grade data available for the selected filters

-

Try selecting different periods or classes

+

No grade data available for {PERIOD_NAMES[selectedPeriod]}

+

Try selecting a different period or classes

) : (
{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 (
@@ -317,10 +301,14 @@ export const Metrics = () => { {classData.period} • {classData.teacher}

+
+ Current + {latest}% +
- + { 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}`} /> - PERIOD_NAMES[value]} + - {Object.keys(classData.nineWeeks).map(nw => ( - - ))}
-
- {Object.entries(classData.nineWeeks).map(([nw, grades]) => { - const latest = getLatestGrade(grades); - const trend = calculateTrend(grades); - return ( -
-
- - {PERIOD_NAMES[nw]} -
-
- {latest}% -
- {getTrendIcon(trend.trend)} - - {Math.abs(trend.change).toFixed(2)}% - -
- ({grades.length} updates) -
-
- ); - })} +
+
+ Trend +
+ {getTrendIcon(trend.trend)} + + {trend.change > 0 ? '+' : ''}{trend.change.toFixed(2)}% + +
+
+
+ Updates + {gradeData.length} +
+
+ Period + + {PERIOD_NAMES[selectedPeriod]} + +
);