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 (
+
+
+
+ My Dashboard
+
+
+ Admin Analytics
+
+
+
+
+ } />
+ } />
+ } />
+
+
+ );
+}
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 =>
+ {h} )}
+
+
+ {riskStudents.map((s,i) => {
+ const risk = riskLevel(s);
+ return (
+
+ {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 && (
+ { setQuery(''); inputRef.current?.focus(); }}
+ style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
+ background: 'none', border: 'none', cursor: 'pointer', fontSize: 16, color: '#9ca3af' }}>✕
+ )}
+
+
+
+ {['all','active','excused'].map(s => (
+ { setStatusFilter(s); setPage(1); }}
+ style={{ padding: '7px 14px', borderRadius: 20, border: '1.5px solid',
+ borderColor: statusFilter === s ? '#4f46e5' : '#d1d5db',
+ background: statusFilter === s ? '#4f46e5' : '#fff',
+ color: statusFilter === s ? '#fff' : '#374151',
+ fontSize: 12, fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize' }}>{s}
+ ))}
+
+
+
setSortBy(e.target.value)}
+ style={{ padding: '7px 12px', borderRadius: 10, border: '1.5px solid #d1d5db', fontSize: 12,
+ background: '#fff', cursor: 'pointer' }}>
+ Sort: SP (High → Low)
+ Sort: Name (A → Z)
+
+
+
+ {q &&
+ {filtered.length} result{filtered.length !== 1 ? 's' : ''} for "{query}"
}
+
+ {visible.length === 0 ? (
+
+ {q ? `No students match "${query}".` : 'No students found.'}
+
+ ) : (
+
+
+ # Student League
+ SP Status
+
+ {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 (
+
{children}
+ );
+}
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 (
+
+ );
+}
+
+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
+
+
+
+
${netSp>=0?'+':''}${netSp}
Net SP
+
${student.totalSp ?? '—'}
Balance
+
+
+
Attendance
+${weekAttendance.length === 0 ? '
No attendance data this week.
' :
+ `
Session Minutes % Status
+ ${weekAttendance.map(r => `
+ ${r.sessionLabel} ${r.attendedMinutes ?? '—'} min
+ ${(r.attendancePercentage||0).toFixed(0)}%
+ ${r.qualified ? '✓ Qualified' : '✗ Below threshold'}
+ `).join('')}
+
`}
+
+
Poll Performance
+${weekPolls.length === 0 ? '
No poll data this week.
' :
+ `
Session Attempted Total %
+ ${weekPolls.map(r => { const p = r.totalQuestions > 0 ? Math.round(r.attemptedQuestions/r.totalQuestions*100) : 0;
+ return `${r.sessionLabel} ${r.attemptedQuestions} ${r.totalQuestions}
+ ${p}% `;}).join('')}
+
`}
+
+
Consistency
+
+
${weekSessions.length}
Sessions
+
+
${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 =>
+ {h} )}
+ {weekAttendance.map((r, i) =>
+
+ {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 =>
+ {h} )}
+ {weekPolls.map((r, i) => {
+ const p = r.totalQuestions > 0
+ ? Math.round(r.attemptedQuestions / r.totalQuestions * 100) : 0;
+ return
+ {r.sessionLabel}
+ {r.attemptedQuestions}
+ {r.totalQuestions}
+ {p}%
+ ;})}
+
+
}
+
+
+
+
+
+
+ {suggestions.map((s, i) =>
+
+
+ {netSp >= 0 ? '✓' : '→'}
+
+ {s}
+ )}
+
+
+ >)}
+
+ );
+}
+
+function Btn({ children, onClick, disabled, active, primary }) {
+ return (
+
{children}
+ );
+}
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) => `
+
+ ${esc(student.name)}
+ ${esc(maskEmail(student.email))}
+ ${student.alternateEmail && student.alternateEmail !== student.email ? `${esc(maskEmail(student.alternateEmail))} ` : ''}
+ Confirm email to view SP
+
+ `).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))}
+
+
+
+ Confirm
+
+
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 = `
+
+
+
+
+ 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 .
+
+
+
Go check your SP here
+
+
+
+
+
+
+
+
+
+
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(),