diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17c7306 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +## [1.1.0] — Engagement features release + +### Added + +- **Weekly Report Generator** (`client/src/components/WeeklyReport.jsx`) + Per-student weekly summary: SP gained/lost/net, attendance table, poll table, + consistency rate, auto-generated suggestions, and a "Download PDF" button + that opens the browser print dialog with a formatted report (no server-side + PDF generation needed). + +- **Activity Heatmap** (`client/src/components/ActivityHeatmap.jsx`) + GitHub-style contribution grid showing daily SP activity over the last 16 + weeks. Hover tooltips show date, SP earned, and session name. Tracks active + days, total SP, and current streak. + +- **Student Search** (`client/src/components/StudentSearch.jsx`) + Instant client-side filtering by name or email, status pills (All/Active/ + Excused), sort by SP or name, trophy league badges, rank medals for top 3, + pagination, and email masking for student-facing privacy. + +- **Analytics Dashboard** (`client/src/components/AnalyticsDashboard.jsx`) + Admin-only dashboard: 8 stat cards (avg/max/min SP, active count, at-risk + count, avg attendance %, avg poll %, session count), SP distribution pie, + status breakdown donut, attendance/poll bar charts per session, weekly SP + trend line chart, trophy league distribution bar, and an at-risk students + table (SP < 100). + +- **New pages**: `StudentDashboardPage.jsx` (student-facing) and + `AdminAnalyticsPage.jsx` (admin-facing) wiring the above components to live + API data via the new `useFetch` hook. + +- **New server endpoints** in `server/server.js`: + `GET /api/me`, `GET /api/leaderboard`, `GET /api/students/search`, + `GET /api/admin/students`, `GET /api/student/:id`, + `GET /api/student/:id/transactions`, `GET /api/student/:id/attendance`, + `GET /api/student/:id/polls`, `GET /api/student/:id/weekly-report`, + `GET /api/sessions`, `GET /api/admin/chat-sp-reviews`, + `POST /api/admin/chat-sp-reviews/:id/accept`, + `POST /api/admin/chat-sp-reviews/:id/reject`, + `GET /api/admin/analytics/latest`. + +- **New Mongoose models**: `Student`, `SPTransaction`, `Session`, + `AttendanceRecord`, `PollRecord`, `ChatRecord`, `ChatSPReview`, + `AnalyticsSnapshot`, `SessionEvent` — matching the schema documented in + `CONTEXT.md`. + +- **Client scaffold**: Vite + React 18 + React Router + Recharts, with a + proxy from `/api` (port 5291) to the backend (port 5290) for local dev. + +### Dependencies added + +- `client/package.json`: `react-router-dom@^6.26.0`, `recharts@^2.12.7`, + `@vitejs/plugin-react@^4.3.1`, `vite@^5.4.1` + +### Notes + +- `AnalyticsDashboard` exposes all student emails and SP — gate the + `/admin/analytics` route behind admin auth before deploying. +- `StudentSearch` defaults to `allowFullSearch={false}` (masked emails); only + the admin page passes `allowFullSearch={true}`. diff --git a/client/package-lock.json b/client/package-lock.json index b8bb10c..fedb2bc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,12 +9,23 @@ "version": "1.0.0", "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "analysis-summership": "file:..", "react": "^18.3.1", "react-dom": "^18.3.1", "vite": "^5.4.11" }, "devDependencies": {} }, + "..": { + "name": "analysis-summership", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.4.2", + "express": "^4.19.2", + "mongoose": "^8.8.4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -782,9 +793,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +806,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +819,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +832,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +845,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -862,9 +858,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -878,9 +871,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -894,9 +884,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -910,9 +897,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -926,9 +910,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -942,9 +923,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -958,9 +936,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -974,9 +949,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1128,6 +1100,10 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/analysis-summership": { + "resolved": "..", + "link": true + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", diff --git a/client/package.json b/client/package.json index 9d7a24f..cf0d1e4 100644 --- a/client/package.json +++ b/client/package.json @@ -9,9 +9,9 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", - "vite": "^5.4.11", + "analysis-summership": "file:..", "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": {} + "react-dom": "^18.3.1", + "vite": "^5.4.11" + } } diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..25e8315 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,25 @@ +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; +import StudentDashboardPage from './pages/StudentDashboardPage'; +import AdminAnalyticsPage from './pages/AdminAnalyticsPage'; + +export default function App() { + return ( + + + + + } /> + } /> + } /> + + + ); +} diff --git a/client/src/components/ActivityHeatmap.jsx b/client/src/components/ActivityHeatmap.jsx new file mode 100644 index 0000000..0eae4db --- /dev/null +++ b/client/src/components/ActivityHeatmap.jsx @@ -0,0 +1,152 @@ +/** + * ActivityHeatmap.jsx — Feature 10 + * GitHub-style daily activity heatmap. + * Props: transactions, sessions, attendanceRecords, startDate, endDate + */ +import { useMemo, useState } from 'react'; + +const DAY_LABELS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; +const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + +const toKey = d => { const x = new Date(d); return `${x.getFullYear()}-${String(x.getMonth()+1).padStart(2,'0')}-${String(x.getDate()).padStart(2,'0')}`; }; +const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; }; +const getMonday = d => { const x = new Date(d); const day = x.getDay(); x.setDate(x.getDate() - ((day + 6) % 7)); x.setHours(0,0,0,0); return x; }; + +const level = sp => sp <= 0 ? 0 : sp <= 3 ? 1 : sp <= 8 ? 2 : sp <= 15 ? 3 : 4; +const COLORS = ['#eef2ff','#c7d2fe','#818cf8','#4f46e5','#312e81']; +const LABELS = ['No activity','Low (1–3 SP)','Moderate (4–8 SP)','Good (9–15 SP)','Excellent (16+ SP)']; + +export default function ActivityHeatmap({ transactions = [], attendanceRecords = [], sessions = [], + startDate, endDate }) { + + const [tip, setTip] = useState(null); + + const sessionByDate = useMemo(() => { + const m = {}; + sessions.forEach(s => { const k = toKey(s.date); (m[k] = m[k] || []).push(s.label); }); + return m; + }, [sessions]); + + const spByDay = useMemo(() => { + const m = {}; + transactions.forEach(t => { const k = toKey(t.dateTime); m[k] = (m[k] || 0) + (t.appliedDelta || 0); }); + return m; + }, [transactions]); + + const today = new Date(); today.setHours(23,59,59,999); + const end = endDate ? new Date(endDate) : today; + const start = startDate ? getMonday(new Date(startDate)) : getMonday(addDays(end, -7*16+1)); + + const weeks = useMemo(() => { + const cols = []; let cur = new Date(start); + while (cur <= end) { + const week = []; + for (let d = 0; d < 7; d++) { + const day = addDays(cur, d); + if (day > end) { week.push(null); continue; } + const k = toKey(day); + week.push({ date: day, key: k, sp: spByDay[k] || 0, sessions: sessionByDate[k] || [] }); + } + cols.push(week); + cur = addDays(cur, 7); + } + return cols; + }, [start, end, spByDay, sessionByDate]); + + const monthLabels = useMemo(() => { + const labels = []; let last = -1; + weeks.forEach((week, wi) => { + const first = week.find(Boolean); + if (!first) return; + const m = first.date.getMonth(); + if (m !== last) { labels.push({ wi, label: MONTH_NAMES[m] }); last = m; } + }); + return labels; + }, [weeks]); + + const CELL = 14, GAP = 3, STEP = CELL + GAP; + + const activeDays = Object.values(spByDay).filter(v => v > 0).length; + const totalSp = Object.values(spByDay).reduce((a, b) => a + b, 0); + const streak = useMemo(() => { + let s = 0, d = new Date(today); + while ((spByDay[toKey(d)] || 0) > 0) { s++; d = addDays(d, -1); } + return s; + }, [spByDay]); + + return ( +
+
+

🟩 Activity Heatmap

+
+ {activeDays} active days + {totalSp >= 0 ? '+' : ''}{totalSp} SP + {streak}-day streak +
+
+ +
+
+
+ {weeks.map((_, wi) => { + const ml = monthLabels.find(m => m.wi === wi); + return
{ml ? ml.label : ''}
; + })} +
+
+
+ {DAY_LABELS.map((d, i) => ( +
{i % 2 === 0 ? d : ''}
+ ))} +
+ {weeks.map((week, wi) => ( +
+ {week.map((cell, di) => cell === null ? ( +
+ ) : ( +
{ const r = e.target.getBoundingClientRect(); + setTip({ x: r.left + window.scrollX, y: r.top + window.scrollY, ...cell }); }} + onMouseLeave={() => setTip(null)} + style={{ width: CELL, height: CELL, borderRadius: 3, background: COLORS[level(cell.sp)], + cursor: cell.sessions.length || cell.sp ? 'pointer' : 'default', + border: cell.date.toDateString() === new Date().toDateString() ? '2px solid #4f46e5' : 'none', + transition: 'transform .1s' }} + onMouseOver={e => (e.currentTarget.style.transform = 'scale(1.3)')} + onMouseOut={e => (e.currentTarget.style.transform = 'scale(1)')} + /> + ))} +
+ ))} +
+
+
+ +
+ Less + {COLORS.map((c, i) => ( +
+ ))} + More + SP per day +
+ + {tip && ( +
+
+ {tip.date.toLocaleDateString('en-IN', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })} +
+
0 ? '#86efac' : tip.sp < 0 ? '#fca5a5' : '#9ca3af' }}> + {tip.sp > 0 ? '+' : ''}{tip.sp} SP +
+ {tip.sessions.length > 0 && +
{tip.sessions.join(', ')}
} +
+ )} +
+ ); +} diff --git a/client/src/components/AnalyticsDashboard.jsx b/client/src/components/AnalyticsDashboard.jsx new file mode 100644 index 0000000..69c23bb --- /dev/null +++ b/client/src/components/AnalyticsDashboard.jsx @@ -0,0 +1,282 @@ +/** + * AnalyticsDashboard.jsx — Feature 17 + * Admin analytics dashboard: stat cards + Pie/Bar/Line charts (recharts). + * Props: students, transactions, sessions, attendanceRecords, pollRecords + */ +import { useMemo } from 'react'; +import { + ResponsiveContainer, PieChart, Pie, Cell, Tooltip as RTooltip, Legend, + BarChart, Bar, XAxis, YAxis, CartesianGrid, LineChart, Line, +} from 'recharts'; + +const C = { indigo:'#4f46e5', violet:'#7c3aed', emerald:'#059669', amber:'#d97706', rose:'#e11d48', sky:'#0284c7', slate:'#475569' }; + +const LEAGUE_COLORS = { + Legend:'#f59e0b','Diamond I':'#3b82f6','Diamond II':'#3b82f6','Diamond III':'#3b82f6', + 'Platinum I':'#8b5cf6','Platinum II':'#8b5cf6','Platinum III':'#8b5cf6', + 'Gold I':'#eab308','Gold II':'#eab308','Gold III':'#eab308', + 'Silver I':'#9ca3af','Silver II':'#9ca3af','Silver III':'#9ca3af', + 'Bronze I':'#a78bfa','Bronze II':'#a78bfa','Bronze III':'#a78bfa', +}; + +function Card({ title, value, sub, color, icon }) { + return ( +
+
{icon}
+
+
{value}
+
{title}
+ {sub &&
{sub}
} +
+
+ ); +} + +function ChartBox({ title, children }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +const riskLevel = s => { const sp = s.totalSp ?? 0; return sp < 50 ? 'critical' : sp < 100 ? 'at-risk' : 'healthy'; }; +const avg = arr => arr.length ? Math.round(arr.reduce((a,b)=>a+b,0)/arr.length) : 0; +const toWeekKey = d => { const x = new Date(d); const day = x.getDay(); + const m = new Date(x); m.setDate(x.getDate() - ((day+6)%7)); + return m.toLocaleDateString('en-IN', { day:'2-digit', month:'short' }); }; + +export default function AnalyticsDashboard({ students = [], transactions = [], sessions = [], + attendanceRecords = [], pollRecords = [] }) { + + const active = students.filter(s => s.status === 'active'); + const excused = students.filter(s => s.status === 'excused'); + const spValues = active.map(s => s.totalSp ?? 0); + + const avgSp = avg(spValues); + const maxSp = spValues.length ? Math.max(...spValues) : 0; + const minSp = spValues.length ? Math.min(...spValues) : 0; + const atRisk = active.filter(s => riskLevel(s) !== 'healthy').length; + const critical = active.filter(s => riskLevel(s) === 'critical').length; + const topStudent = active.reduce((a, b) => ((a?.totalSp ?? 0) > (b?.totalSp ?? 0) ? a : b), active[0] || {}); + + const avgAtt = useMemo(() => avg(attendanceRecords.map(r => r.attendancePercentage || 0)), [attendanceRecords]); + const avgPoll = useMemo(() => avg(pollRecords.map(r => r.totalQuestions > 0 ? (r.attemptedQuestions / r.totalQuestions) * 100 : 0)), [pollRecords]); + + const spBands = useMemo(() => { + const bands = [ + { name:'600+', min:600, max:Infinity, color:C.indigo }, + { name:'400–599', min:400, max:599, color:C.sky }, + { name:'200–399', min:200, max:399, color:C.emerald }, + { name:'100–199', min:100, max:199, color:C.amber }, + { name:'<100', min:-Infinity, max:99, color:C.rose }, + ]; + return bands.map(b => ({ name:b.name, color:b.color, + value: active.filter(s => (s.totalSp ?? 0) >= b.min && (s.totalSp ?? 0) <= b.max).length })) + .filter(b => b.value > 0); + }, [active]); + + const statusPie = [ + { name:'Active', value: active.length, color:C.emerald }, + { name:'Excused', value: excused.length, color:C.amber }, + { name:'At Risk', value: atRisk, color:C.rose }, + ].filter(d => d.value > 0); + + const attendanceBar = useMemo(() => { + const m = {}; + attendanceRecords.forEach(r => { if (!m[r.sessionLabel]) m[r.sessionLabel] = { total:0, count:0 }; + m[r.sessionLabel].total += r.attendancePercentage || 0; m[r.sessionLabel].count++; }); + return Object.entries(m).map(([label, {total,count}]) => ({ + label: label.length > 14 ? label.slice(0,12)+'…' : label, fullLabel: label, + avgPct: Math.round(total / count) })).slice(-12); + }, [attendanceRecords]); + + const pollBar = useMemo(() => { + const m = {}; + pollRecords.forEach(r => { if (!m[r.sessionLabel]) m[r.sessionLabel] = { total:0, count:0 }; + const pct = r.totalQuestions > 0 ? (r.attemptedQuestions / r.totalQuestions) * 100 : 0; + m[r.sessionLabel].total += pct; m[r.sessionLabel].count++; }); + return Object.entries(m).map(([label, {total,count}]) => ({ + label: label.length > 14 ? label.slice(0,12)+'…' : label, avgPct: Math.round(total / count) })).slice(-12); + }, [pollRecords]); + + const spTrend = useMemo(() => { + const m = {}; + transactions.forEach(t => { const k = toWeekKey(t.dateTime); m[k] = (m[k] || 0) + (t.appliedDelta || 0); }); + return Object.entries(m).map(([week, totalDelta]) => ({ week, totalDelta })).slice(-12); + }, [transactions]); + + const riskStudents = useMemo(() => + active.filter(s => riskLevel(s) !== 'healthy').sort((a,b) => (a.totalSp??0)-(b.totalSp??0)).slice(0,8), + [active]); + + const leagueBar = useMemo(() => { + const ORDER = ['Legend','Diamond I','Diamond II','Diamond III','Platinum I','Platinum II','Platinum III', + 'Gold I','Gold II','Gold III','Silver I','Silver II','Silver III','Bronze I','Bronze II','Bronze III']; + const counts = {}; + active.forEach(s => { if (s.trophyLeague) counts[s.trophyLeague] = (counts[s.trophyLeague]||0)+1; }); + return ORDER.filter(l => counts[l]).map(l => ({ label:l, count:counts[l], color:LEAGUE_COLORS[l] || '#9ca3af' })); + }, [active]); + + const pctFmt = v => `${v}%`; + + return ( +
+

📊 Analytics Dashboard

+

+ {active.length} active students across {sessions.length} sessions +

+ +
+ + + + + + + + +
+ +
+ + + + `${name} ${(percent*100).toFixed(0)}%`}> + {spBands.map((d,i) => )} + + [`${v} students`,'']} /> + + + + + + + + + `${name}: ${value}`}> + {statusPie.map((d,i) => )} + + [`${v} students`,'']} /> + + + + +
+ +
+ + {attendanceBar.length === 0 ? : ( + + + + + + p[0]?.payload?.fullLabel} /> + + {attendanceBar.map((d,i) => =75?C.emerald:d.avgPct>=50?C.amber:C.rose} />)} + + + + )} + + + + {pollBar.length === 0 ? : ( + + + + + + + + {pollBar.map((d,i) => =75?C.indigo:d.avgPct>=50?C.violet:C.rose} />)} + + + + )} + +
+ +
+ + {spTrend.length === 0 ? : ( + + + + + + [`${v>=0?'+':''}${v} SP`,'Net SP']} /> + + + + )} + + + + {leagueBar.length === 0 ? : ( + + + + + + [`${v} students`,'']} /> + + {leagueBar.map((d,i) => )} + + + + )} + +
+ + {riskStudents.length > 0 && ( +
+

⚠️ Students at Risk (low SP)

+ + + {['Name','Email','SP','Risk Level'].map(h => + )} + + + {riskStudents.map((s,i) => { + const risk = riskLevel(s); + return ( + + + + + + + ); + })} + +
{h}
{s.name}{s.email}{s.totalSp ?? 0} + + {risk === 'critical' ? '🔴 Critical' : '🟡 At Risk'} + +
+ {atRisk > 8 &&

+ Showing 8 of {atRisk} at-risk students.

} +
+ )} +
+ ); +} + +const td = { padding:'7px 10px', border:'1px solid #fecaca' }; +function Empty() { + return
No data available yet.
; +} diff --git a/client/src/components/StudentSearch.jsx b/client/src/components/StudentSearch.jsx new file mode 100644 index 0000000..8328ae7 --- /dev/null +++ b/client/src/components/StudentSearch.jsx @@ -0,0 +1,199 @@ +/** + * StudentSearch.jsx — Feature 15 + * Instant filterable student search with privacy masking, sort, and pagination. + * Props: students, onSelect, allowFullSearch + */ +import { useState, useMemo, useRef } from 'react'; + +function maskEmail(email) { + if (!email) return ''; + const [local, domain] = email.split('@'); + if (!domain) return email; + return `${local.slice(0,2)}${'*'.repeat(Math.max(2, local.length - 2))}@${domain}`; +} + +function highlight(text, q) { + if (!q) return text; + const i = text.toLowerCase().indexOf(q.toLowerCase()); + if (i === -1) return text; + return (<> + {text.slice(0, i)} + + {text.slice(i, i + q.length)} + + {text.slice(i + q.length)} + ); +} + +function leagueStyle(l) { + if (l === 'Legend') return { bg: '#fef08a', fg: '#713f12', border: '#ca8a04' }; + if (l?.startsWith('Diamond')) return { bg: '#bfdbfe', fg: '#1e3a8a', border: '#3b82f6' }; + if (l?.startsWith('Platinum')) return { bg: '#e0e7ff', fg: '#3730a3', border: '#6366f1' }; + if (l?.startsWith('Gold')) return { bg: '#fef9c3', fg: '#a16207', border: '#eab308' }; + if (l?.startsWith('Silver')) return { bg: '#f3f4f6', fg: '#374151', border: '#9ca3af' }; + return { bg: '#fef2f2', fg: '#7f1d1d', border: '#fca5a5' }; +} + +const spColor = sp => sp >= 600 ? '#4f46e5' : sp >= 400 ? '#16a34a' : sp >= 200 ? '#ca8a04' : sp >= 100 ? '#374151' : '#dc2626'; + +export default function StudentSearch({ students = [], onSelect, allowFullSearch = false }) { + const [query, setQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortBy, setSortBy] = useState('sp'); + const [page, setPage] = useState(1); + const inputRef = useRef(null); + const PAGE_SIZE = 20; + const q = query.trim().toLowerCase(); + + const filtered = useMemo(() => { + let list = students; + if (statusFilter !== 'all') list = list.filter(s => s.status === statusFilter); + if (q) { + list = list.filter(s => { + const nameMatch = s.name?.toLowerCase().includes(q); + const emailMatch = allowFullSearch ? s.email?.toLowerCase().includes(q) : s.email?.toLowerCase() === q; + const altMatch = allowFullSearch && s.alternateEmail?.toLowerCase().includes(q); + return nameMatch || emailMatch || altMatch; + }); + } + if (sortBy === 'sp') list = [...list].sort((a, b) => (b.totalSp || 0) - (a.totalSp || 0)); + if (sortBy === 'name') list = [...list].sort((a, b) => (a.name || '').localeCompare(b.name || '')); + return list; + }, [students, q, statusFilter, sortBy, allowFullSearch]); + + const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1; + const page_ = Math.min(page, totalPages); + const visible = filtered.slice((page_ - 1) * PAGE_SIZE, page_ * PAGE_SIZE); + + const rankMap = useMemo(() => { + const sorted = [...students].filter(s => s.status === 'active').sort((a, b) => (b.totalSp || 0) - (a.totalSp || 0)); + const m = {}; sorted.forEach((s, i) => (m[s.email] = i + 1)); return m; + }, [students]); + + return ( +
+
+

🔍 Search Students

+

+ {students.length} students total — {students.filter(s => s.status === 'active').length} active +

+
+ +
+
+ 🔍 + { setQuery(e.target.value); setPage(1); }} + placeholder="Search by name or email…" + style={{ width: '100%', padding: '9px 12px 9px 34px', border: '1.5px solid #d1d5db', borderRadius: 10, + fontSize: 13, outline: 'none', background: '#fafafa', boxSizing: 'border-box' }} + onFocus={e => (e.target.style.borderColor = '#4f46e5')} + onBlur={e => (e.target.style.borderColor = '#d1d5db')} /> + {query && ( + + )} +
+ +
+ {['all','active','excused'].map(s => ( + + ))} +
+ + +
+ + {q &&
+ {filtered.length} result{filtered.length !== 1 ? 's' : ''} for "{query}"
} + + {visible.length === 0 ? ( +
+ {q ? `No students match "${query}".` : 'No students found.'} +
+ ) : ( +
+
+ #StudentLeague + SPStatus +
+ {visible.map((s, i) => { + const rank = rankMap[s.email]; + const ls = leagueStyle(s.trophyLeague); + return ( +
onSelect?.(s)} + style={{ display: 'grid', gridTemplateColumns: '40px 1fr 140px 80px 100px', padding: '11px 14px', + gap: 8, alignItems: 'center', borderBottom: '1px solid #f3f4f6', + cursor: onSelect ? 'pointer' : 'default', background: '#fff' }} + onMouseOver={e => (e.currentTarget.style.background = '#f0f0ff')} + onMouseOut={e => (e.currentTarget.style.background = '#fff')}> + + {rank ? (rank <= 3 ? ['🥇','🥈','🥉'][rank-1] : `#${rank}`) : '—'} + +
+
+ {highlight(s.name || '(no name)', query)} +
+
+ {allowFullSearch ? highlight(s.email || '', query) : maskEmail(s.email)} +
+
+
+ {s.trophyLeague ? ( + + {s.trophyLeague} + + ) : } +
+
+ {s.totalSp ?? 0} +
+
+ {s.status} +
+
+ ); + })} +
+ )} + + {totalPages > 1 && ( +
+ setPage(1)} disabled={page_ === 1}>« + setPage(p => Math.max(1, p - 1))} disabled={page_ === 1}>‹ + + Page {page_} of {totalPages} ({filtered.length} results) + + setPage(p => Math.min(totalPages, p + 1))} disabled={page_ === totalPages}>› + setPage(totalPages)} disabled={page_ === totalPages}>» +
+ )} +
+ ); +} + +function PgBtn({ children, onClick, disabled }) { + return ( + + ); +} diff --git a/client/src/components/WeeklyReport.jsx b/client/src/components/WeeklyReport.jsx new file mode 100644 index 0000000..ac9b43b --- /dev/null +++ b/client/src/components/WeeklyReport.jsx @@ -0,0 +1,273 @@ +/** + * WeeklyReport.jsx — Feature 7 + * Per-student weekly report with PDF download. + * Props: student, sessions, transactions, attendanceRecords, pollRecords + */ +import { useState, useMemo } from 'react'; + +function getWeekRange(offset = 0) { + const now = new Date(); + const day = now.getDay(); + const mon = new Date(now); + mon.setDate(now.getDate() - ((day + 6) % 7) + offset * 7); + mon.setHours(0, 0, 0, 0); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + sun.setHours(23, 59, 59, 999); + return { start: mon, end: sun }; +} + +const fmt = d => + new Date(d).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + +const pctColor = p => + p >= 90 ? '#16a34a' : p >= 75 ? '#ca8a04' : p >= 50 ? '#f97316' : '#dc2626'; + +function avg(arr) { + if (!arr.length) return null; + return Math.round(arr.reduce((a, b) => a + b, 0) / arr.length); +} + +function StatCard({ value, label, color }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Section({ title, children }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +const TD = { padding: '7px 10px', border: '1px solid #e5e7eb', fontSize: 13 }; +const TH = { ...TD, background: '#f3f4f6', fontWeight: 600, fontSize: 12 }; + +export default function WeeklyReport({ student = {}, sessions = [], transactions = [], + attendanceRecords = [], pollRecords = [] }) { + + const [weekOffset, setWeekOffset] = useState(0); + const { start, end } = useMemo(() => getWeekRange(weekOffset), [weekOffset]); + + const weekSessions = useMemo(() => sessions.filter(s => { const d = new Date(s.date); return d >= start && d <= end; }), [sessions, start, end]); + const weekTx = useMemo(() => transactions.filter(t => { const d = new Date(t.dateTime); return d >= start && d <= end; }), [transactions, start, end]); + const weekAttendance = useMemo(() => attendanceRecords.filter(r => weekSessions.some(s => s.label === r.sessionLabel)), [attendanceRecords, weekSessions]); + const weekPolls = useMemo(() => pollRecords.filter(r => weekSessions.some(s => s.label === r.sessionLabel)), [pollRecords, weekSessions]); + + const spGained = weekTx.filter(t => t.appliedDelta > 0).reduce((a, t) => a + t.appliedDelta, 0); + const spLost = weekTx.filter(t => t.appliedDelta < 0).reduce((a, t) => a + t.appliedDelta, 0); + const netSp = spGained + spLost; + const qualified = weekAttendance.filter(r => r.qualified).length; + const consistency = weekSessions.length ? Math.round((qualified / weekSessions.length) * 100) : null; + const avgAtt = avg(weekAttendance.map(r => r.attendancePercentage || 0)); + const avgPoll = avg(weekPolls.map(r => r.totalQuestions > 0 ? Math.round((r.attemptedQuestions / r.totalQuestions) * 100) : 0)); + + const suggestions = []; + if (avgAtt !== null && avgAtt < 75) suggestions.push(`Average attendance this week was ${avgAtt}% — below the 75% SP threshold. Try joining sessions on time and staying for the full window.`); + if (avgPoll !== null && avgPoll < 75) suggestions.push(`Poll attempt rate was ${avgPoll}%. Answering every poll question is the easiest way to keep SP climbing.`); + if (netSp < 0) suggestions.push(`Net SP this week: ${netSp}. Consistent attendance and full poll participation will recover this next week.`); + if (consistency !== null && consistency < 60) suggestions.push(`You qualified for attendance SP in ${qualified} of ${weekSessions.length} session(s). Aim for 75%+ in every session to stay in the earning band.`); + if (!suggestions.length) suggestions.push('Excellent week! Keep attending on time, attempting every poll, and engaging in chat to stay at the top.'); + + // Build printable HTML string for PDF + const buildPrintHTML = () => ` +Spurti Weekly Report — ${student.name || ''} + +

📊 Spurti Weekly Report

+
${student.name || 'Student'} • ${student.email || ''}
+Week: ${fmt(start)} – ${fmt(end)}
+ +
SP Summary
+
+
+${spGained}
Gained
+
${spLost}
Lost
+
${netSp>=0?'+':''}${netSp}
Net SP
+
${student.totalSp ?? '—'}
Balance
+
+ +
Attendance
+${weekAttendance.length === 0 ? '

No attendance data this week.

' : + ` + ${weekAttendance.map(r => ` + + + + `).join('')} +
SessionMinutes%Status
${r.sessionLabel}${r.attendedMinutes ?? '—'} min${(r.attendancePercentage||0).toFixed(0)}%${r.qualified ? '✓ Qualified' : '✗ Below threshold'}
`} + +
Poll Performance
+${weekPolls.length === 0 ? '

No poll data this week.

' : + ` + ${weekPolls.map(r => { const p = r.totalQuestions > 0 ? Math.round(r.attemptedQuestions/r.totalQuestions*100) : 0; + return ` + `;}).join('')} +
SessionAttemptedTotal%
${r.sessionLabel}${r.attemptedQuestions}${r.totalQuestions}${p}%
`} + +
Consistency
+
+
${weekSessions.length}
Sessions
+
${qualified}
Qualified
+
${consistency!==null?consistency+'%':'—'}
Consistency
+
${avgAtt!==null?avgAtt+'%':'—'}
Avg Att.
+
+ +
Suggestions
+
    ${suggestions.map(s => `
  • ${s}
  • `).join('')}
+`; + + return ( +
+ {/* Header row */} +
+
+

📊 Weekly Report

+

{fmt(start)} – {fmt(end)}

+
+
+ setWeekOffset(w => w - 1)}>← Prev + setWeekOffset(0)}>This Week + setWeekOffset(w => Math.min(w + 1, 0))}>Next → + { + const w = window.open('', '_blank'); + w.document.write(buildPrintHTML()); + w.document.close(); + setTimeout(() => w.print(), 400); + }}>⬇ Download PDF +
+
+ + {/* Student banner */} +
+
+
{student.name || 'Student'}
+
{student.email}
+
+
+
{student.totalSp ?? '—'} SP
+
Current Balance
+
+
+ + {weekSessions.length === 0 ? ( +
+ No sessions found for this week. +
+ ) : (<> +
+
+ + + = 0 ? '+' : ''}${netSp}`} label="Net SP" + color={netSp >= 0 ? '#16a34a' : '#dc2626'} /> + +
+
+ +
+ {weekAttendance.length === 0 + ?

No attendance data this week.

+ : + {['Session','Minutes','%','Status'].map(h => + )} + {weekAttendance.map((r, i) => + + + + + + )} + +
{h}
{r.sessionLabel}{r.attendedMinutes ?? '—'} min + {(r.attendancePercentage || 0).toFixed(0)}% + + {r.qualified ? '✓ Qualified' : '✗ Below threshold'} + +
} +
+ +
+ {weekPolls.length === 0 + ?

No poll data this week.

+ : + {['Session','Attempted','Total','%'].map(h => + )} + {weekPolls.map((r, i) => { + const p = r.totalQuestions > 0 + ? Math.round(r.attemptedQuestions / r.totalQuestions * 100) : 0; + return + + + + + ;})} + +
{h}
{r.sessionLabel}{r.attemptedQuestions}{r.totalQuestions}{p}%
} +
+ +
+
+ + + + + +
+
+ +
+
    + {suggestions.map((s, i) => +
  • + + {netSp >= 0 ? '✓' : '→'} + + {s} +
  • )} +
+
+ )} +
+ ); +} + +function Btn({ children, onClick, disabled, active, primary }) { + return ( + + ); +} diff --git a/client/src/hooks/useFetch.js b/client/src/hooks/useFetch.js new file mode 100644 index 0000000..06bc21b --- /dev/null +++ b/client/src/hooks/useFetch.js @@ -0,0 +1,21 @@ +import { useState, useEffect } from 'react'; + +export default function useFetch(url, deps = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + fetch(url, { credentials: 'include' }) + .then(res => { if (!res.ok) throw new Error(`Request failed: ${res.status}`); return res.json(); }) + .then(json => { if (!cancelled) { setData(json); setError(null); } }) + .catch(err => { if (!cancelled) setError(err.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return { data, loading, error }; +} diff --git a/client/src/pages/AdminAnalyticsPage.jsx b/client/src/pages/AdminAnalyticsPage.jsx new file mode 100644 index 0000000..af866aa --- /dev/null +++ b/client/src/pages/AdminAnalyticsPage.jsx @@ -0,0 +1,55 @@ +/** + * AdminAnalyticsPage.jsx + * Wires up StudentSearch + AnalyticsDashboard for admins. + * Route example: /spurti/admin/analytics (guard this route with admin auth) + */ +import { useState } from 'react'; +import useFetch from '../hooks/useFetch'; +import StudentSearch from '../components/StudentSearch'; +import AnalyticsDashboard from '../components/AnalyticsDashboard'; + +export default function AdminAnalyticsPage() { + const [selected, setSelected] = useState(null); + const { data, loading, error } = useFetch('/api/admin/analytics/latest', []); + + if (loading) return Loading analytics…; + if (error) return Failed to load analytics: {error}; + + const { students = [], transactions = [], sessions = [], attendanceRecords = [], pollRecords = [] } = data || {}; + + return ( +
+ + +
+ + + + {selected && ( +
+ Selected: {selected.name} — {selected.email} — {selected.totalSp} SP +
+ )} +
+ ); +} + +function Centered({ children }) { + return ( +
+ {children} +
+ ); +} diff --git a/client/src/pages/StudentDashboardPage.jsx b/client/src/pages/StudentDashboardPage.jsx new file mode 100644 index 0000000..d17456d --- /dev/null +++ b/client/src/pages/StudentDashboardPage.jsx @@ -0,0 +1,51 @@ +/** + * StudentDashboardPage.jsx + * Wires up WeeklyReport + ActivityHeatmap for the logged-in student. + * Route example: /spurti/dashboard + */ +import useFetch from '../hooks/useFetch'; +import WeeklyReport from '../components/WeeklyReport'; +import ActivityHeatmap from '../components/ActivityHeatmap'; + +export default function StudentDashboardPage() { + const { data: me, loading: meLoading } = useFetch('/api/me', []); + + const studentId = me?.profile?.student?._id; + const { data: report, loading: reportLoading } = useFetch( + studentId ? `/api/student/${studentId}/weekly-report` : null, + [studentId] + ); + + if (meLoading) return Loading…; + if (!me?.authenticated) return Please open Spurti from your Samagama dashboard.; + if (reportLoading || !report) return Loading your report…; + + const { student, sessions, transactions, attendanceRecords, pollRecords } = report; + + return ( +
+ +
+ +
+ ); +} + +function Centered({ children }) { + return ( +
+ {children} +
+ ); +} diff --git a/server/models/Student.js b/server/models/Student.js index 1b3e7f2..2f27b56 100644 --- a/server/models/Student.js +++ b/server/models/Student.js @@ -6,7 +6,7 @@ const studentSchema = new mongoose.Schema({ alternateEmail: { type: String, lowercase: true, trim: true, default: '', index: true }, internshipStartDate: { type: Date, required: true, index: true }, internshipEndDate: { type: Date, default: null }, - status: { type: String, enum: ['active', 'excused'], default: 'active', index: true }, + status: { type: String, enum: ['active', 'excused',"yet to onboard"], default: 'active', index: true }, excusedAt: { type: Date, default: null }, excusedReason: { type: String, default: '' }, totalSp: { type: Number, default: 100, index: true }, diff --git a/server/public/app.js b/server/public/app.js new file mode 100644 index 0000000..ff9ca60 --- /dev/null +++ b/server/public/app.js @@ -0,0 +1,236 @@ +const sessionLabels = ['15 May Morning', '15 May Evening', '16 May Morning', '16 May Evening', '17 May Weekend Special']; +let students = []; +let pendingStudentEmail = ''; + +const byId = (id) => document.getElementById(id); +const n = (value) => Number(value || 0); +const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}[char])); + +function hasActivity(student) { + return Array.isArray(student.activities) && student.activities.length > 0; +} + +function findMatches(query) { + const value = query.trim().toLowerCase(); + if (value.length < 2) return []; + return students + .filter((student) => `${student.name} ${student.email} ${student.alternateEmail}`.toLowerCase().includes(value)) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 8); +} + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +function isExactEmailSearch(query) { + const value = normalizeEmail(query); + return value.includes('@') && students.some((student) => normalizeEmail(student.email) === value || normalizeEmail(student.alternateEmail) === value); +} + +function exactEmailMatch(query) { + const value = normalizeEmail(query); + return students.find((student) => normalizeEmail(student.email) === value || normalizeEmail(student.alternateEmail) === value); +} + +function maskEmail(email) { + const value = String(email || '').trim(); + const [name, domain] = value.split('@'); + if (!name || !domain) return 'hidden email'; + const visibleStart = name.slice(0, Math.min(2, name.length)); + const visibleEnd = name.length > 4 ? name.slice(-2) : ''; + return `${visibleStart}${'*'.repeat(Math.max(3, name.length - visibleStart.length - visibleEnd.length))}${visibleEnd}@${domain}`; +} + +function renderMatches(matches) { + const box = byId('matches'); + if (!byId('searchInput').value.trim()) { + box.innerHTML = ''; + return; + } + if (!matches.length) { + box.innerHTML = '
No matching student record found.
'; + return; + } + box.innerHTML = ` +

Select your record

+
+ ${matches.map((student) => ` + + `).join('')} +
+ `; +} + +function renderConfirmation(student) { + pendingStudentEmail = student.email; + byId('record').innerHTML = ''; + byId('matches').innerHTML = ` +
+

Confirm your email

+

To protect records with duplicate or similar names, enter your full email before viewing this SP record.

+
+ ${esc(student.name)} + ${esc(maskEmail(student.email))} +
+
+ + +
+
Use the same email used in the roster/attendance record.
+
+ `; + byId('confirmEmail').focus(); +} + +function sessionLedger(student) { + return sessionLabels.map((label) => { + const minutes = n(student.sessions?.[label]); + const entry = student.sp.sessionLedger.find((item) => item.label === label); + const spClass = entry.sp > 0 ? 'plus' : entry.sp < 0 ? 'minus' : 'neutral-sp'; + const spText = entry.sp > 0 ? `+${entry.sp} SP` : `${entry.sp} SP`; + return ` +
+
+ ${esc(label)} + ${esc(entry.reason)} +
+ ${spText} +
+ `; + }).join(''); +} + +function activityBlock(student) { + if (!hasActivity(student)) { + return '
No activity participation found, so no activity SP was added.
'; + } + return student.activities.map((activity) => ` +
+ ${esc(activity.item || 'Activity submitted')} + ${esc(activity.matched || 'Unknown')} +
+ `).join(''); +} + +function renderRecord(student) { + byId('record').innerHTML = ` +
+
+
+

Student record

+

${esc(student.name)}

+

${esc(student.email)}${student.alternateEmail && student.alternateEmail !== student.email ? ` · ${esc(student.alternateEmail)}` : ''}

+
+
+ Final SP + ${student.sp.total} +
+
+ +
+
Initial SP${student.sp.initial}
+
Attendance SP${student.sp.attendance > 0 ? '+' : ''}${student.sp.attendance}
+
Activity SP+${student.sp.activity}
+
Sessions${student.sessionsAttended}/${sessionLabels.length}
+
+ +
+

Attendance ledger

+
${sessionLedger(student)}
+
+ +
+

Game/activity ledger

+

${esc(student.sp.activityReason)}: +${student.sp.activity} SP

+
${activityBlock(student)}
+
+
+ `; +} + +window.selectStudent = (email) => { + const student = students.find((item) => item.email === email); + if (student) { + byId('matches').innerHTML = ''; + renderRecord(student); + } +}; + +window.requestEmailConfirmation = (email) => { + const student = students.find((item) => item.email === email); + if (student) renderConfirmation(student); +}; + +window.confirmSelectedStudent = () => { + const student = students.find((item) => item.email === pendingStudentEmail); + const typed = normalizeEmail(byId('confirmEmail')?.value); + const message = byId('confirmMsg'); + if (!student) return; + if (typed === normalizeEmail(student.email) || typed === normalizeEmail(student.alternateEmail)) { + byId('matches').innerHTML = ''; + renderRecord(student); + return; + } + message.textContent = 'Email did not match this record.'; + message.classList.add('error-text'); +}; + +function runSearch() { + byId('record').innerHTML = ''; + pendingStudentEmail = ''; + const query = byId('searchInput').value; + if (isExactEmailSearch(query)) { + byId('matches').innerHTML = ''; + renderRecord(exactEmailMatch(query)); + return; + } + const matches = findMatches(query); + renderMatches(matches); +} + +function showSearchPage() { + byId('introPage').classList.add('hidden'); + byId('searchPage').classList.remove('hidden'); + byId('searchInput').focus(); +} + +function showIntroPage() { + byId('searchPage').classList.add('hidden'); + byId('introPage').classList.remove('hidden'); + byId('searchInput').value = ''; + byId('matches').innerHTML = ''; + byId('record').innerHTML = ''; +} + +async function init() { + const response = await fetch('/api/students'); + const payload = await response.json(); + students = payload.students || []; + byId('startSearch').addEventListener('click', showSearchPage); + byId('backHome').addEventListener('click', showIntroPage); + byId('searchBtn').addEventListener('click', runSearch); + byId('searchInput').addEventListener('keydown', (event) => { + if (event.key === 'Enter') runSearch(); + }); + byId('searchInput').addEventListener('input', () => { + byId('searchHint').textContent = byId('searchInput').value.trim().length < 2 + ? 'Type at least 2 characters to search.' + : 'Press Enter or click Search.'; + }); +} + +init().catch((error) => { + byId('introPage').innerHTML = `
Failed to load student data: ${esc(error.message)}
`; +}); diff --git a/server/public/index.html b/server/public/index.html new file mode 100644 index 0000000..faa1059 --- /dev/null +++ b/server/public/index.html @@ -0,0 +1,95 @@ + + + + + + Summership SP Record + + + +
+
+
+
Spurti Motivation Engine
+

Your presence, effort, and participation now count.

+

+ Spurti Points are a simple way to track learning energy through the program. Every session you attend, + every activity you try, and every meaningful participation signal helps build your record. +

+
+ Presence matters. + We are tracking attendance and activity because consistent participation is part of learning, not just final marks. +
+
+
+ 100 SP + You begin with a base +

Every student starts with 100 Spurti Points as the initial motivation balance.

+
+
+ 75% + Attendance must qualify +

You get +5 SP only if you attend at least 75% of a session. Partial attendance below 75% gives 0 SP. Absence gives -5 SP.

+
+
+ +10 / +5 + Activities add more +

If your item matched, you get +10 SP. If you participated but did not match, you get +5 SP.

+
+
+
+
+
Absolute SP example
+

Current SP = 110. Attendance gives +10 SP. New SP = 120.

+
+
+
Percentage SP example
+

Current SP = 110. Activity gives +10%. SP added = 11. New SP = 121.

+
+
+
Deduction example
+

Current SP = 110. Missed activity deducts 10 SP. New SP = 100.

+
+
+ +
+ +
+ + +
+ + + + diff --git a/server/public/styles.css b/server/public/styles.css new file mode 100644 index 0000000..a84fec0 --- /dev/null +++ b/server/public/styles.css @@ -0,0 +1,406 @@ +:root { + --bg: #eef2f6; + --panel: #ffffff; + --text: #101828; + --muted: #667085; + --line: #d8dee8; + --primary: #0f766e; + --blue: #2563eb; + --green: #047857; + --amber: #b45309; + --red: #b42318; + --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", Arial, sans-serif; +} +button, input { font: inherit; } +button { cursor: pointer; } +.hidden { display: none !important; } +.muted { color: var(--muted); } + +.app-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 34px 0 54px; +} + +.intro-page { + min-height: calc(100vh - 88px); + display: grid; + grid-template-columns: minmax(0, 1.25fr) 360px; + gap: 22px; + align-items: center; +} +.intro-copy, .score-preview, .search-panel, .student-record, .matches { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow); +} +.intro-copy { padding: 34px; } +.eyebrow { + margin: 0 0 10px; + color: var(--primary); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} +h1 { + margin: 0; + max-width: 760px; + font-size: 44px; + line-height: 1.04; +} +.lead { + max-width: 760px; + margin: 16px 0 0; + color: var(--muted); + font-size: 18px; + line-height: 1.55; +} +.motivation-band { + display: grid; + gap: 5px; + margin-top: 20px; + padding: 15px 16px; + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 10px; +} +.motivation-band strong { + color: var(--primary); + font-size: 16px; +} +.motivation-band span { + color: #344054; + line-height: 1.45; +} +.rule-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 22px 0 14px; +} +.rule-card { + background: #f8fafc; + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px; +} +.rule-value { + display: block; + margin-bottom: 10px; + color: var(--primary); + font-size: 24px; + font-weight: 900; +} +.rule-card strong { display: block; } +.rule-card p { + margin: 8px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.45; +} +.example-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 14px 0 24px; +} +.example-card { + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px; + background: #fff; +} +.example-title { + color: var(--blue); + font-size: 13px; + font-weight: 850; + margin-bottom: 7px; +} +.example-card p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.45; +} +.example-card strong { color: var(--text); } +.primary-btn, .ghost-btn { + min-height: 44px; + border: 0; + border-radius: 9px; + padding: 0 18px; + font-weight: 850; +} +.primary-btn { background: var(--primary); color: white; } +.ghost-btn { + background: white; + color: var(--text); + border: 1px solid var(--line); +} +.score-preview { padding: 20px; } +.preview-head { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 18px; + margin-bottom: 12px; + border-bottom: 1px solid var(--line); +} +.preview-head span, .preview-row span { color: var(--muted); } +.preview-head strong { + color: var(--primary); + font-size: 48px; +} +.preview-row { + display: flex; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} +.preview-note { + margin-top: 16px; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.search-page { + display: grid; + gap: 18px; +} +.search-header { + display: flex; + gap: 16px; + align-items: flex-start; + padding-top: 8px; +} +.search-header h1 { + font-size: 34px; +} +.search-header p { + margin: 8px 0 0; + color: var(--muted); +} +.search-panel { padding: 18px; } +.search-panel label { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 850; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} +.search-row input { + height: 44px; + border: 1px solid var(--line); + border-radius: 9px; + padding: 0 14px; +} +.search-hint { + margin-top: 10px; + color: var(--muted); + font-size: 13px; +} + +.matches { padding: 18px; } +.matches h2 { margin: 0 0 12px; font-size: 18px; } +.match-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} +.match-card { + text-align: left; + border: 1px solid var(--line); + background: #fff; + border-radius: 10px; + padding: 12px; +} +.match-card strong, .match-card span, .match-card em { + display: block; +} +.match-card span { + margin-top: 4px; + color: var(--muted); + font-size: 13px; +} +.match-card em { + margin-top: 8px; + color: var(--primary); + font-style: normal; + font-weight: 900; +} +.confirm-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 18px; +} +.confirm-card h2 { + margin: 0 0 8px; + font-size: 22px; +} +.confirm-target { + display: grid; + gap: 3px; + margin: 14px 0; + padding: 12px; + background: #f8fafc; + border: 1px solid var(--line); + border-radius: 9px; +} +.confirm-target span { + color: var(--muted); + font-size: 13px; +} +.error-text { color: var(--red); } + +.record-panel { display: grid; } +.student-record { padding: 22px; } +.record-head { + display: flex; + justify-content: space-between; + gap: 16px; + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--line); +} +.record-head h2 { + margin: 0; + font-size: 30px; +} +.record-head p:not(.eyebrow) { + margin: 6px 0 0; + color: var(--muted); +} +.final-score { + min-width: 150px; + text-align: right; +} +.final-score span { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} +.final-score strong { + display: block; + color: var(--primary); + font-size: 52px; + line-height: 1; +} +.score-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 20px; +} +.score-grid div { + background: #f8fafc; + border: 1px solid var(--line); + border-radius: 10px; + padding: 14px; +} +.score-grid span { + display: block; + color: var(--muted); + font-size: 12px; +} +.score-grid strong { + display: block; + margin-top: 5px; + font-size: 25px; +} +.record-section { + margin-top: 20px; +} +.record-section h3 { + margin: 0 0 10px; + font-size: 18px; +} +.ledger { + display: grid; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} +.ledger-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 13px 14px; + border-top: 1px solid var(--line); +} +.ledger-row:first-child { border-top: 0; } +.ledger-row span { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 13px; +} +.plus { color: var(--green); } +.minus { color: var(--red); } +.neutral-sp { color: var(--amber); } +.activity-list { + display: grid; + gap: 8px; + margin-top: 12px; +} +.activity-row { + display: flex; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--line); + border-radius: 9px; + padding: 12px; + background: #fbfcfe; +} +.empty { + padding: 18px; + color: var(--muted); + background: #f8fafc; + border: 1px dashed var(--line); + border-radius: 10px; +} +.empty.compact { padding: 12px; } + +@media (max-width: 920px) { + .intro-page { + grid-template-columns: 1fr; + align-items: start; + } + .rule-grid, .example-grid, .score-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 640px) { + .app-shell { width: min(100% - 24px, 1180px); padding-top: 18px; } + .intro-copy, .student-record { padding: 18px; } + h1 { font-size: 34px; } + .search-row, .record-head { + grid-template-columns: 1fr; + display: grid; + } + .final-score { text-align: left; } + .ledger-row, .activity-row { + display: grid; + } +} diff --git a/server/server.js b/server/server.js index 11f6e4f..aeac36e 100644 --- a/server/server.js +++ b/server/server.js @@ -433,7 +433,23 @@ api.get('/admin/leaderboard', adminGuard, async (req, res) => { totalSp: s.totalSp }))); }); - +api.get('/sessions', async (_req, res) => { + try { + const sessions = await Session.find({}) + .sort({ endDateTime: 1 }) + .lean(); + + res.json( + sessions.map(s => ({ + label: s.label, + date: s.endDateTime, + duration: s.totalMinutes + })) + ); + } catch (err) { + res.status(500).json({ error: 'Failed to load sessions' }); + } +}); api.get('/admin/attendance', adminGuard, async (_req, res) => { const [sessions, students, records] = await Promise.all([ Session.find().sort({ endDateTime: 1 }).lean(),