diff --git a/CONTEXT.md b/CONTEXT.md index 8b7f837..d60312e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -142,6 +142,64 @@ scoring — the `pipeline/` rubric is authoritative. The old Zoom ±5 ingest - `POST /api/admin/chat-sp-reviews/:id/accept` — award SP - `POST /api/admin/chat-sp-reviews/:id/reject` — reject +## Engagement Classification (Chunks 1–7, 2026-07-15) + +Classifies students into 4 engagement bands based on a rolling window of sessions. + +### Files +- `server/engagement/config.js` — rolling window size (N=3), band thresholds, 4 band labels +- `server/engagement/fetchData.js` — fetches attendance + SP transactions, splits into current/previous windows +- `server/engagement/classifyBand.js` — pure function: `classifyBand(current, previous)` → `{ band, reason, stats }` +- `server/routes/engagement.js` — Express router for the single-student endpoint + +### Bands +| Band | Criteria | Description | +|------|----------|-------------| +| **Excellent** | avg attendance ≥90%, avg SP ≥8/session | High attendance, strong SP gain | +| **Active** | avg attendance ≥75%, avg SP ≥3/session | Consistent attendance, moderate SP | +| **Slowing Down** | avg attendance <75% OR declining trend | Dropping off, risk of falling behind | +| **Recovery** | prior window was Slowing Down, now improving | Trend reversal detected | + +### Endpoints +- `GET /api/engagement/:email` — Single student engagement band + window summary + - Response: `{ email, name, totalSp, band, reason, stats, windows: { current, previous } }` +- `GET /api/admin/engagement/report` — All active students grouped by band (admin auth required) + - Optional: `?band=Excellent|Active|Slowing Down|Recovery` to filter + - Response: `{ summary: { Excellent: { count }, ... }, total, groups }` + - Auth headers: `x-admin-email: dled@iitrpr.ac.in`, `x-admin-token: vled-local-admin` + +## Journey Tracker (Chunks 1–9, 2026-07-16) + +Tracks student progress across three time windows with admin-configurable targets. + +### Files +- `server/models/JourneyTarget.js` — Mongoose model for DB-stored target configs +- `server/journey/targets.js` — defaults + get/upsert helpers (fallback to defaults if no DB entry) +- `server/journey/dateRange.js` — resolves `weekly|monthly|tenure` to `{ start, end, label }` +- `server/journey/computeJourney.js` — fetches attendance+polls within range, computes overall %, determines checkpoints +- `server/routes/journey.js` — Express router for the student-facing endpoint +- `client/src/components/journey/JourneyTracker.jsx` — student-facing route with checkpoint dots + metric bars +- `client/src/components/journey/AdminJourneyTargets.jsx` — admin form to edit targets + +### Default Targets +| Window | Checkpoints | Att Target | Poll Target | Weight | +|--------|-------------|-----------|-------------|--------| +| Weekly | 5 | 80% | 75% | 50/50 | +| Monthly | 4 | 85% | 80% | 50/50 | +| Tenure | 8 | 75% | 70% | 50/50 | + +### Progress Calculation +- `overallPct = attendancePct * (attendanceWeight/100) + pollPct * (pollWeight/100)` +- Checkpoints reached = `floor(elapsedTimeRatio * checkpointCount)` + +### Endpoints +- `GET /api/journey/:email?window=weekly|monthly|tenure` — Student journey progress + - Response: `{ window, range, target, progress, checkpoints, sessions }` +- `GET /api/admin/journey/targets` — All targets (DB override or default) +- `PUT /api/admin/journey/targets/:window` — Save custom target (admin auth required) + - Auth headers: `x-admin-email: dled@iitrpr.ac.in`, `x-admin-token: vled-local-admin` + - Body: `{ label, checkpointCount, attendanceTargetPct, pollTargetPct, attendanceWeight, pollWeight }` + ## Auth — `chatengine_token` cookie passthrough (LIVE since 2026-06-29) Spurti lives at `samagama.in/spurti` (same domain as Samagama), so the browser already holds the student's **`chatengine_token`** cookie. There is **no login diff --git a/client/index.html b/client/index.html index 1ab52c4..eba5a5a 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,7 @@ Summership SP Record +
diff --git a/client/package-lock.json b/client/package-lock.json index b8bb10c..1d79aee 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,11 +9,21 @@ "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", @@ -782,9 +792,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +805,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +818,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +831,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +844,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -862,9 +857,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -878,9 +870,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -894,9 +883,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -910,9 +896,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -926,9 +909,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -942,9 +922,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -958,9 +935,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -974,9 +948,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1128,6 +1099,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/components/engagement/AdminBandGrid.jsx b/client/src/components/engagement/AdminBandGrid.jsx new file mode 100644 index 0000000..e78daef --- /dev/null +++ b/client/src/components/engagement/AdminBandGrid.jsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import BlockIcon from './BlockIcon'; + +const BAND_ORDER = ['Excellent', 'Active', 'Recovery', 'Slowing Down']; + +export default function AdminBandGrid({ auth }) { + const [groups, setGroups] = useState(null); + const [summary, setSummary] = useState(null); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [hovered, setHovered] = useState(null); + + const headers = auth ? { + 'X-Admin-Email': auth.email, + 'X-Admin-Token': auth.token + } : {}; + + useEffect(() => { + if (!auth) return; + setLoading(true); + const base = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; + const url = filter ? `${base}/api/admin/engagement/report?band=${encodeURIComponent(filter)}` : `${base}/api/admin/engagement/report`; + fetch(url, { headers }) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (!d) return; + if (filter) { + setGroups({ [filter]: d.students || [] }); + setSummary({ [filter]: { count: d.count } }); + setTotal(d.count); + } else { + setGroups(d.groups || {}); + setSummary(d.summary || {}); + setTotal(d.total || 0); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [auth, filter]); + + if (!auth) { + return

Admin login required to view engagement report.

; + } + + if (loading) { + return ( +
+
Loading report...
+
+ ); + } + + const filteredGroups = filter ? { [filter]: groups?.[filter] || [] } : groups || {}; + + return ( +
+
+

Engagement Report

+ {total} students +
+ +
+ + {BAND_ORDER.map(band => ( + + ))} +
+ +
+ {BAND_ORDER.map(band => { + const students = filteredGroups[band]; + if (!students || students.length === 0) return null; + return ( +
+

+ + {band} + {students.length} student{students.length !== 1 ? 's' : ''} +

+
+ {students.map(s => ( +
setHovered(s.email)} + onMouseLeave={() => setHovered(null)} + > + + {hovered === s.email && ( +
+ {s.name}
+ {s.reason} +
+ )} +
+ ))} +
+
+ ); + })} +
+ + {Object.keys(filteredGroups).length === 0 && ( +

No students found in this band.

+ )} +
+ ); +} diff --git a/client/src/components/engagement/BlockIcon.jsx b/client/src/components/engagement/BlockIcon.jsx new file mode 100644 index 0000000..14be49a --- /dev/null +++ b/client/src/components/engagement/BlockIcon.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +const BAND_MAP = { + 'Excellent': 'Excellent', + 'Active': 'Active', + 'Slowing Down': 'Slowing', + 'Recovery': 'Recovery' +}; + +function getFleckPositions(size) { + if (size === 'sm') return [[6,6], [20,18], [14,10]]; + if (size === 'md') return [[10,8], [30,26], [20,14], [36,10]]; + if (size === 'lg') return [[12,12], [40,36], [26,18], [48,10], [20,44]]; + return [[4,4], [14,12], [10,7]]; +} + +export default function BlockIcon({ band, size = 'md', dimmed = false, showTooltip = false, reason = '' }) { + const cls = BAND_MAP[band] || 'Insufficient'; + const sizeCls = `mc-block-${size}`; + const flecks = getFleckPositions(size); + + const block = ( +
+ {flecks.map(([x, y], i) => ( + + ))} +
+ ); + + if (!showTooltip || !reason) return block; + + return ( +
+ {block} +
{reason}
+
+ ); +} diff --git a/client/src/components/engagement/StudentBandCard.jsx b/client/src/components/engagement/StudentBandCard.jsx new file mode 100644 index 0000000..114ae9c --- /dev/null +++ b/client/src/components/engagement/StudentBandCard.jsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import BlockIcon from './BlockIcon'; + +const BAND_ORDER = ['Slowing Down', 'Recovery', 'Active', 'Excellent']; + +export default function StudentBandCard({ email }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!email) return; + setData(null); + setError(null); + const base = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; + fetch(`${base}/api/engagement/${encodeURIComponent(email)}`) + .then(r => r.ok ? r.json() : null) + .then(d => d ? setData(d) : setError('No engagement data')) + .catch(() => setError('Failed to load')); + }, [email]); + + if (error) return null; + if (!data) { + return ( +
+
Loading band...
+
+ ); + } + + const { band, reason, stats } = data; + const currentAtt = stats?.avgAttendancePct ?? '—'; + const currentSp = stats?.avgSpPerSession ?? '—'; + + return ( +
+
+

Progress Band

+ + Att: {currentAtt}% · SP/session: {currentSp} + +
+ +
+ {BAND_ORDER.map((b, i) => { + const isActive = b === band; + return ( + + {i > 0 && ( +
= i ? ' active' : ''}`} /> + )} +
+ + {b} +
+ + ); + })} +
+ +

{reason}

+
+ ); +} diff --git a/client/src/components/journey/AdminJourneyTargets.jsx b/client/src/components/journey/AdminJourneyTargets.jsx new file mode 100644 index 0000000..25151dd --- /dev/null +++ b/client/src/components/journey/AdminJourneyTargets.jsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; + +const WINDOWS = ['weekly', 'monthly', 'tenure']; +const FIELD_META = { + label: { label: 'Label', type: 'text' }, + checkpointCount: { label: 'Checkpoints', type: 'number', min: 1, max: 10 }, + attendanceTargetPct: { label: 'Att Target %', type: 'number', min: 0, max: 100 }, + pollTargetPct: { label: 'Poll Target %', type: 'number', min: 0, max: 100 }, + attendanceWeight: { label: 'Att Weight %', type: 'number', min: 0, max: 100 }, + pollWeight: { label: 'Poll Weight %', type: 'number', min: 0, max: 100 } +}; + +function getBase() { + return window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; +} + +export default function AdminJourneyTargets({ auth }) { + const [targets, setTargets] = useState(null); + const [edit, setEdit] = useState(null); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(''); + const headers = auth ? { 'X-Admin-Email': auth.email, 'X-Admin-Token': auth.token } : {}; + + useEffect(() => { + if (!auth) return; + fetch(`${getBase()}/api/admin/journey/targets`, { headers }) + .then(r => r.ok ? r.json() : null) + .then(d => setTargets(d)); + }, [auth]); + + if (!auth) return

Admin login required.

; + if (!targets) return

Loading targets...

; + + const startEdit = (window) => setEdit({ window, ...targets[window] }); + + const save = async () => { + if (!edit) return; + setSaving(true); setMsg(''); + try { + const r = await fetch(`${getBase()}/api/admin/journey/targets/${edit.window}`, { + method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: edit.label, checkpointCount: Number(edit.checkpointCount), + attendanceTargetPct: Number(edit.attendanceTargetPct), pollTargetPct: Number(edit.pollTargetPct), + attendanceWeight: Number(edit.attendanceWeight), pollWeight: Number(edit.pollWeight) + }) + }); + if (!r.ok) throw new Error('Save failed'); + const updated = await r.json(); + setTargets(prev => ({ ...prev, [edit.window]: updated })); + setMsg('Saved successfully'); + setTimeout(() => setMsg(''), 2000); + } catch (e) { setMsg('Error: ' + e.message); } + finally { setSaving(false); } + }; + + return ( +
+

Journey Targets

+ {msg &&

{msg}

} +
+ {WINDOWS.map(w => { + const t = targets[w]; + const isEditing = edit?.window === w; + return ( +
+
+

{w}

+ {!isEditing && } +
+ {!isEditing ? ( +
+
Label
{t.label}
+
Checkpoints
{t.checkpointCount}
+
Att
{t.attendanceTargetPct}%
+
Poll
{t.pollTargetPct}%
+
Att Wt
{t.attendanceWeight}%
+
Poll Wt
{t.pollWeight}%
+
+ ) : ( +
+
+ {Object.entries(FIELD_META).map(([key, meta]) => ( +
+ + setEdit(prev => ({ ...prev, [key]: e.target.value }))} + style={{ marginTop: 4 }} + /> +
+ ))} +
+
+ + +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/components/journey/JourneyTracker.jsx b/client/src/components/journey/JourneyTracker.jsx new file mode 100644 index 0000000..a766ad4 --- /dev/null +++ b/client/src/components/journey/JourneyTracker.jsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from 'react'; + +const WINDOWS = ['weekly', 'monthly', 'tenure']; + +const BIOME = { + weekly: { label: 'Week', biomeClass: 'journey-biome-weekly', beamColor: '#7CB342', name: 'Plains' }, + monthly: { label: 'Month', biomeClass: 'journey-biome-monthly', beamColor: '#8BC34A', name: 'Forest' }, + tenure: { label: 'Tenure', biomeClass: 'journey-biome-tenure', beamColor: '#CE93D8', name: 'Nether' } +}; + +function getBase() { + return window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; +} + +function xpColor(pct) { + if (pct >= 80) return '#5B9BD5'; + if (pct >= 60) return '#F5B342'; + if (pct >= 40) return '#C4695C'; + return '#6B6B6B'; +} + +export default function JourneyTracker({ email }) { + const [activeWindow, setActiveWindow] = useState('weekly'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!email) return; + setLoading(true); + fetch(`${getBase()}/api/journey/${encodeURIComponent(email)}?window=${activeWindow}`) + .then(r => r.ok ? r.json() : null) + .then(d => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, [email, activeWindow]); + + const biome = BIOME[activeWindow]; + + if (loading) { + return ( +
+

Loading journey...

+
+ ); + } + + if (!data) return null; + + const { target, progress, checkpoints, range } = data; + + return ( +
+
+ + {/* Header */} +
+
+
+ {range.label} +
+

Journey Tracker

+
+
+ {WINDOWS.map(w => { + const b = BIOME[w]; + const isActive = activeWindow === w; + return ( + + ); + })} +
+
+ + {/* Waypoints */} +
+ {checkpoints.map((cp, i) => ( + + {i > 0 && ( +
+ )} +
+
+ {cp.reached &&
} +
+ {cp.label} +
+ + ))} +
+ + {/* XP Bars */} +
+ + + +
+ + {/* Footer */} +
+ {progress.checkpointsReached}/{progress.totalCheckpoints} milestones + Target: att {target.attendanceTargetPct}% · poll {target.pollTargetPct}% +
+
+ ); +} + +function JourneyBar({ label, value, color }) { + return ( +
+ {label} +
+
+
+ {value}% +
+ ); +} diff --git a/client/src/main.jsx b/client/src/main.jsx index 9f98b28..2712e9e 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,6 +1,11 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import './styles.css'; +import './styles/minecraft-tokens.css'; +import StudentBandCard from './components/engagement/StudentBandCard'; +import AdminBandGrid from './components/engagement/AdminBandGrid'; +import AdminJourneyTargets from './components/journey/AdminJourneyTargets'; +import JourneyTracker from './components/journey/JourneyTracker'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -285,6 +290,8 @@ function StudentView({ profile, onBack }) {
SP{student.totalSp}Rank {student.rank} of {student.cohortSize}
+ +

Admin Dashboard

Spurti Control Room

Yet to onboard{stats?.yetToOnboard ?? admin.yetToOnboard ?? 0}|Active{stats?.activeStudents ?? admin.activeStudents ?? admin.students ?? 0}|Excused{stats?.excusedStudents ?? admin.excusedStudents ?? 0}{stats?.transactions ?? admin.transactions ?? 0} txns
- + {tab === 'leaderboard' && (
@@ -1202,6 +1209,8 @@ function AdminView({ admin, auth, onBack }) { {tab === 'attendance' && } {tab === 'live' && } {tab === 'analytics' && } + {tab === 'engagement' && } + {tab === 'targets' && } {tab === 'students' && } {studentProfile &&

{studentProfile.student.name}

} diff --git a/client/src/styles/minecraft-tokens.css b/client/src/styles/minecraft-tokens.css new file mode 100644 index 0000000..51206c7 --- /dev/null +++ b/client/src/styles/minecraft-tokens.css @@ -0,0 +1,528 @@ +:root { + --mc-redstone: #C4695C; + --mc-redstone-dark: #8D4A3E; + --mc-redstone-fleck: #D99B8F; + --mc-gold: #F5B342; + --mc-gold-dark: #C67C00; + --mc-gold-fleck: #FFD966; + --mc-emerald: #4CAF50; + --mc-emerald-dark: #388E3C; + --mc-emerald-fleck: #81C784; + --mc-diamond: #5B9BD5; + --mc-diamond-dark: #3A7BBF; + --mc-diamond-fleck: #89C4F4; + --mc-cobble: #8A8A8A; + --mc-cobble-dark: #5C5C5C; + --mc-cobble-fleck: #A8A8A8; + + --mc-bevel-light: rgba(255,255,255,0.30); + --mc-bevel-dark: rgba(0,0,0,0.30); + --mc-tooltip-bg: #100010ee; +} + +.mc-glow-Excellent { box-shadow: 0 0 12px rgba(91,155,213,0.6), 0 0 24px rgba(91,155,213,0.3); } +.mc-glow-Active { box-shadow: 0 0 12px rgba(76,175,80,0.6), 0 0 24px rgba(76,175,80,0.3); } +.mc-glow-Slowing { box-shadow: 0 0 12px rgba(196,105,92,0.5), 0 0 24px rgba(196,105,92,0.25); } +.mc-glow-Recovery { box-shadow: 0 0 12px rgba(245,179,66,0.6), 0 0 24px rgba(245,179,66,0.3); } + +.mc-block { + display: inline-flex; + align-items: center; + justify-content: center; + border-top: 3px solid var(--mc-bevel-light); + border-left: 3px solid var(--mc-bevel-light); + border-bottom: 3px solid var(--mc-bevel-dark); + border-right: 3px solid var(--mc-bevel-dark); + image-rendering: pixelated; + position: relative; + overflow: hidden; +} + +.mc-block-sm { width: 32px; height: 32px; } +.mc-block-md { width: 48px; height: 48px; } +.mc-block-lg { width: 64px; height: 64px; } + +.mc-fleck { + position: absolute; + width: 3px; + height: 3px; + border-radius: 0; + opacity: 0.5; +} + +/* Band-specific block colors */ +.mc-band-Excellent { + background: var(--mc-diamond); + box-shadow: inset 0 0 0 1px var(--mc-diamond-dark); +} +.mc-band-Excellent .mc-fleck { background: var(--mc-diamond-fleck); } + +.mc-band-Active { + background: var(--mc-emerald); + box-shadow: inset 0 0 0 1px var(--mc-emerald-dark); +} +.mc-band-Active .mc-fleck { background: var(--mc-emerald-fleck); } + +.mc-band-Slowing { + background: var(--mc-redstone); + box-shadow: inset 0 0 0 1px var(--mc-redstone-dark); +} +.mc-band-Slowing .mc-fleck { background: var(--mc-redstone-fleck); } + +.mc-band-Recovery { + background: var(--mc-gold); + box-shadow: inset 0 0 0 1px var(--mc-gold-dark); +} +.mc-band-Recovery .mc-fleck { background: var(--mc-gold-fleck); } + +.mc-band-Insufficient { + background: var(--mc-cobble); + box-shadow: inset 0 0 0 1px var(--mc-cobble-dark); +} +.mc-band-Insufficient .mc-fleck { background: var(--mc-cobble-fleck); } + +/* Tooltip style - Minecraft inventory item tooltip */ +.mc-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--mc-tooltip-bg); + color: #fff; + font-size: 12px; + line-height: 1.4; + padding: 8px 10px; + white-space: nowrap; + pointer-events: none; + z-index: 100; + border-top: 2px solid var(--mc-bevel-light); + border-left: 2px solid var(--mc-bevel-light); + border-bottom: 2px solid var(--mc-bevel-dark); + border-right: 2px solid var(--mc-bevel-dark); + max-width: 280px; + white-space: normal; + text-align: center; +} + +.mc-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--mc-tooltip-bg); +} + +/* Pixel font for band labels */ +.mc-label { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; +} + +.mc-label-lg { + font-family: 'Press Start 2P', monospace; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; +} + +/* Band card container */ +.mc-card { + border: 2px solid var(--line); + padding: 12px; + display: flex; + align-items: center; + gap: 14px; +} + +.mc-card-info { + display: grid; + gap: 4px; +} + +.mc-card-info .mc-label { + color: var(--text); +} + +.mc-card-info .mc-reason { + font-size: 13px; + color: var(--muted); + line-height: 1.4; + margin: 2px 0 0; +} + +.mc-card-info .mc-stats { + display: flex; + gap: 12px; + margin-top: 4px; +} + +.mc-card-info .mc-stat { + font-size: 11px; + color: var(--muted); +} + +.mc-card-info .mc-stat strong { + color: var(--text); + font-size: 13px; +} + +/* History trail */ +.mc-trail { + display: flex; + gap: 4px; + align-items: center; + margin-left: auto; +} + +.mc-trail-label { + font-size: 10px; + color: var(--muted); + margin-right: 4px; +} + +/* Admin grid */ +.mc-grid-wrap { + display: grid; + gap: 16px; +} + +.mc-band-row { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel); +} + +.mc-band-row h3 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.mc-band-row h3 span { + font-size: 11px; + color: var(--muted); + font-weight: 400; +} + +.mc-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.mc-grid-item { + position: relative; + cursor: pointer; +} + +.mc-filter-bar { + display: flex; + gap: 8px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.mc-filter-btn { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + letter-spacing: 0.5px; + border: 2px solid var(--line); + background: var(--panel); + padding: 8px 12px; + cursor: pointer; + text-transform: uppercase; + border-top: 3px solid var(--mc-bevel-light); + border-left: 3px solid var(--mc-bevel-light); + border-bottom: 3px solid var(--mc-bevel-dark); + border-right: 3px solid var(--mc-bevel-dark); +} + +.mc-filter-btn.active { + background: var(--primary); + color: #fff; + border-color: var(--primary-dark); +} + +.mc-loading { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: var(--muted); + text-align: center; + padding: 20px; +} + +.mc-dimmed { + filter: saturate(0.2) brightness(0.65); + opacity: 0.6; +} + +.mc-active-block { + filter: saturate(1.1) brightness(1.05); +} + +.mc-spectrum { + display: flex; + align-items: center; + gap: 12px; + width: 100%; +} + +.mc-spectrum-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + flex: 1; + position: relative; +} + +.mc-spectrum-item .mc-label { + font-size: 7px; + color: var(--muted); + transition: color 0.2s; +} + +.mc-spectrum-item.active .mc-label { + color: var(--text); + font-size: 8px; + font-weight: 700; +} + +.mc-spectrum-item.active .mc-label-lg { + font-size: 10px; + color: var(--text); +} + +.mc-spectrum-connector { + flex: 0 0 auto; + height: 4px; + width: 20px; + background: #d0d7e2; + margin: 0 -8px; + border-radius: 2px; + transition: background 0.3s; +} + +.mc-spectrum-connector.active { + background: var(--primary); +} + +.mc-spec-card { + border: 2px solid var(--line); + border-radius: 8px; + padding: 16px; + background: var(--panel); + box-shadow: var(--shadow); + margin-bottom: 18px; +} + +.mc-spec-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.mc-spec-header h3 { + margin: 0; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.mc-spec-reason { + font-size: 12px; + color: var(--muted); + margin: 8px 0 0; + text-align: center; + line-height: 1.4; +} + +/* Journey Tracker - Clean Minecraft-style */ +.journey-biome-weekly { + position: relative; +} +.journey-biome-weekly .journey-biome-bar { + background: linear-gradient(90deg, transparent, #7CB34244, #7CB34266, #7CB34244, transparent); + height: 3px; + margin: -16px -16px 12px -16px; + border-radius: 0; +} + +.journey-biome-monthly { + position: relative; +} +.journey-biome-monthly .journey-biome-bar { + background: linear-gradient(90deg, transparent, #5D403744, #5D403766, #5D403744, transparent); + height: 3px; + margin: -16px -16px 12px -16px; + border-radius: 0; +} + +.journey-biome-tenure { + position: relative; +} +.journey-biome-tenure .journey-biome-bar { + background: linear-gradient(90deg, transparent, #7B1FA244, #7B1FA266, #7B1FA244, transparent); + height: 3px; + margin: -16px -16px 12px -16px; + border-radius: 0; +} + +/* Window selector */ +.journey-window-selector { + display: flex; + gap: 2px; + background: #f1f5f9; + border-radius: 6px; + padding: 2px; +} + +.journey-window-btn { + display: flex; + align-items: center; + gap: 5px; + border: none; + background: transparent; + padding: 6px 12px; + font-size: 12px; + font-weight: 700; + color: #64748b; + cursor: pointer; + border-radius: 5px; + transition: all 0.15s; + font-family: inherit; +} + +.journey-window-btn.active { + background: #fff; + color: #172033; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.journey-window-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--btn-beam, #ccc); + flex-shrink: 0; +} + +/* Waypoint row */ +.journey-waypoint-row { + display: flex; + align-items: center; + padding: 14px 0 18px; +} + +.journey-waypoint-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.journey-waypoint-pin { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2.5px solid; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; +} + +.journey-waypoint-check { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.journey-waypoint-item.reached .journey-waypoint-pin { + transform: scale(1.1); +} + +.journey-waypoint-label { + font-family: 'Press Start 2P', monospace; + font-size: 7px; + color: #64748b; + text-align: center; + line-height: 1.3; +} + +.journey-waypoint-item.reached .journey-waypoint-label { + color: #172033; + font-weight: 700; +} + +.journey-waypoint-connector { + flex: 0 0 auto; + width: 24px; + height: 3px; + margin: 0 -2px; + margin-bottom: 26px; + border-radius: 2px; + transition: background 0.3s; +} + +/* Journey bars (replaces XP bars) */ +.journey-bar-row { + display: grid; + grid-template-columns: 80px 1fr 38px; + align-items: center; + gap: 10px; +} + +.journey-bar-label { + font-size: 11px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.journey-bar-track { + height: 10px; + background: #e2e8f0; + border-radius: 5px; + overflow: hidden; +} + +.journey-bar-fill { + height: 100%; + border-radius: 5px; + transition: width 0.6s ease; + min-width: 4px; +} + +.journey-bar-value { + font-size: 13px; + font-weight: 800; + text-align: right; +} + +/* Footer */ +.journey-footer { + display: flex; + justify-content: space-between; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #d9e1ec; + font-size: 11px; + color: #64748b; +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..3e88971 --- /dev/null +++ b/config.js @@ -0,0 +1,60 @@ +/** + * Engagement / Progress Bands — configuration + * + * These are the only tunable knobs for the classifier. Nothing in + * classifyBand.js or computeEngagement.js should hardcode a number — + * everything that could plausibly need retuning by an admin/mentor + * lives here instead. + * + * Band labels are copied verbatim from PRODUCT.md's + * "Reward And Motivation Design" section: + * "Weekly progress bands such as Excellent, Active, Slowing Down, + * and Recovery." + */ + +// Number of most-recent sessions considered the "current window" +// when classifying a student's band. The same number of sessions +// immediately before that is used as the "previous window" for +// Recovery detection. +const WINDOW_SIZE = 3; + +// Canonical band names — use these constants everywhere instead of +// string literals, so a typo can't silently create a 5th "band". +const BANDS = { + EXCELLENT: 'Excellent', + ACTIVE: 'Active', + SLOWING_DOWN: 'Slowing Down', + RECOVERY: 'Recovery', + INSUFFICIENT_DATA: 'Not enough data yet', +}; + +// Thresholds used by classifyBand.js. +// attendanceRate is fraction of sessions attended in the window (0–1). +// negativeSessionsAllowed is how many sessions in the window are +// allowed to have a negative net SP delta before it stops counting +// as "Active". +const THRESHOLDS = { + EXCELLENT: { + minAttendanceRate: 0.9, + maxNegativeSessions: 0, + }, + ACTIVE: { + minAttendanceRate: 0.6, + maxNegativeSessions: 1, + }, + // "Slowing Down" is trend-based (declining across the window), + // not a fixed threshold — see classifyBand.js for the comparison + // logic. This flag just controls how strict "declining" means. + SLOWING_DOWN: { + // require at least this much drop in attendance rate OR net SP + // delta between the first and last session in the window before + // it's classified as declining, to avoid flagging normal noise. + minDeclineMargin: 0.15, + }, +}; + +module.exports = { + WINDOW_SIZE, + BANDS, + THRESHOLDS, +}; diff --git a/server/engagement/classifyBand.js b/server/engagement/classifyBand.js new file mode 100644 index 0000000..ab62246 --- /dev/null +++ b/server/engagement/classifyBand.js @@ -0,0 +1,74 @@ +import { ENGAGEMENT_BANDS, ENGAGEMENT_THRESHOLDS, ROLLING_WINDOW_SIZE } from './config.js'; + +function avg(arr, key) { + const vals = arr.map(s => s[key]).filter(v => v !== null && v !== undefined); + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; +} + +function isDeclining(window) { + if (window.length < 2) return false; + const att = window.map(s => s.attendancePct).filter(v => v !== null); + const sp = window.map(s => s.spDelta); + if (att.length >= 2 && att[att.length - 1] < att[0]) return true; + if (sp.length >= 2 && sp[sp.length - 1] < sp[0]) return true; + return false; +} + +function isImproving(window) { + if (window.length < 2) return false; + const att = window.map(s => s.attendancePct).filter(v => v !== null); + const sp = window.map(s => s.spDelta); + if (att.length >= 2 && att[att.length - 1] > att[0]) return true; + if (sp.length >= 2 && sp[sp.length - 1] > sp[0]) return true; + return false; +} + +function hasData(window) { + return window.length > 0 && window.some(s => s.attendancePct !== null); +} + +export function classifyBand(currentWindow, previousWindow = []) { + if (!hasData(currentWindow)) { + return { band: ENGAGEMENT_BANDS.ACTIVE, reason: 'No attendance data available yet' }; + } + + const avgAtt = avg(currentWindow, 'attendancePct'); + const avgSp = avg(currentWindow, 'spDelta'); + const declining = isDeclining(currentWindow); + const improving = isImproving(currentWindow); + + if (previousWindow.length > 0) { + const prev = classifyBand(previousWindow); + if (prev.band === ENGAGEMENT_BANDS.SLOWING_DOWN && improving) { + return { + band: ENGAGEMENT_BANDS.RECOVERY, + reason: `Attendance improved to ${Math.round(avgAtt)}%, SP trend reversing — recovered from Slowing Down`, + stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 } + }; + } + } + + if (avgAtt >= ENGAGEMENT_THRESHOLDS.excellent.minAttendancePct && avgSp >= ENGAGEMENT_THRESHOLDS.excellent.minSpPerSession) { + return { + band: ENGAGEMENT_BANDS.EXCELLENT, + reason: `Avg attendance ${Math.round(avgAtt)}% (≥90%), avg SP +${Math.round(avgSp)} per session`, + stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 } + }; + } + + if (avgAtt >= ENGAGEMENT_THRESHOLDS.active.minAttendancePct && avgSp >= ENGAGEMENT_THRESHOLDS.active.minSpPerSession) { + const trendNote = declining ? ' but showing early decline signs' : ' — steady engagement'; + return { + band: ENGAGEMENT_BANDS.ACTIVE, + reason: `Avg attendance ${Math.round(avgAtt)}% (≥75%), avg SP +${Math.round(avgSp)} per session${trendNote}`, + stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 } + }; + } + + const declineNote = declining ? 'trend declining' : 'below active thresholds'; + return { + band: ENGAGEMENT_BANDS.SLOWING_DOWN, + reason: `Attendance at ${Math.round(avgAtt)}%, SP +${Math.round(avgSp)}/session — ${declineNote}`, + stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 } + }; +} diff --git a/server/engagement/config.js b/server/engagement/config.js new file mode 100644 index 0000000..269e9f1 --- /dev/null +++ b/server/engagement/config.js @@ -0,0 +1,46 @@ +export const ROLLING_WINDOW_SIZE = 3; + +export const ATTENDANCE_BANDS = [ + { label: 'excellent', minPct: 90 }, + { label: 'good', minPct: 75 }, + { label: 'fair', minPct: 50 }, + { label: 'low', minPct: 0 } +]; + +export const SP_DELTA_BANDS = [ + { label: 'strong', minDelta: 10 }, + { label: 'moderate', minDelta: 5 }, + { label: 'slight', minDelta: 3 }, + { label: 'none', minDelta: 0 } +]; + +export const ENGAGEMENT_BANDS = { + EXCELLENT: 'Excellent', + ACTIVE: 'Active', + SLOWING_DOWN: 'Slowing Down', + RECOVERY: 'Recovery' +}; + +export const ENGAGEMENT_THRESHOLDS = { + excellent: { + minAttendancePct: 90, + minSpPerSession: 8, + description: 'High attendance and strong SP gain across the window' + }, + active: { + minAttendancePct: 75, + minSpPerSession: 3, + description: 'Consistent attendance and moderate SP gain' + }, + slowingDown: { + maxAttendancePct: 74, + maxSpPerSession: 2, + decliningTrendRequired: true, + description: 'Declining attendance or SP trend over the window' + }, + recovery: { + minAttendancePct: 75, + priorBandRequired: 'Slowing Down', + description: 'Improved from a prior Slowing Down trend' + } +}; diff --git a/server/engagement/fetchData.js b/server/engagement/fetchData.js new file mode 100644 index 0000000..3370d97 --- /dev/null +++ b/server/engagement/fetchData.js @@ -0,0 +1,37 @@ +import { ROLLING_WINDOW_SIZE } from './config.js'; +import AttendanceRecord from '../models/AttendanceRecord.js'; +import SPTransaction from '../models/SPTransaction.js'; +import Session from '../models/Session.js'; + +export async function fetchStudentEngagementData(email) { + const [sessions, attendanceRecords, spTransactions] = await Promise.all([ + Session.find().sort({ endDateTime: 1 }).lean(), + AttendanceRecord.find({ email }).lean(), + SPTransaction.find({ email, category: { $ne: 'initial' } }).sort({ dateTime: 1 }).lean() + ]); + + const attendanceByLabel = {}; + for (const rec of attendanceRecords) { + attendanceByLabel[rec.sessionLabel] = rec.attendancePercentage; + } + + const spByLabel = {}; + for (const txn of spTransactions) { + if (!spByLabel[txn.sessionLabel]) spByLabel[txn.sessionLabel] = 0; + spByLabel[txn.sessionLabel] += txn.appliedDelta; + } + + const windowed = sessions.map(s => ({ + label: s.label, + date: s.endDateTime, + totalMinutes: s.totalMinutes, + attendancePct: attendanceByLabel[s.label] ?? null, + spDelta: spByLabel[s.label] ?? 0 + })); + + const n = ROLLING_WINDOW_SIZE; + const current = windowed.slice(-n); + const previous = windowed.length > n ? windowed.slice(-n * 2, -n) : []; + + return { current, previous, all: windowed }; +} diff --git a/server/engagement/seed-demo.js b/server/engagement/seed-demo.js new file mode 100644 index 0000000..b0abdc8 --- /dev/null +++ b/server/engagement/seed-demo.js @@ -0,0 +1,125 @@ +import mongoose from 'mongoose'; +import { MONGO_URI } from '../config.js'; +import Student from '../models/Student.js'; +import Session from '../models/Session.js'; +import AttendanceRecord from '../models/AttendanceRecord.js'; +import SPTransaction from '../models/SPTransaction.js'; + +const students = [ + { + name: 'Ananya Sharma', email: 'ananya@test.com', + status: 'active', totalSp: 180, sessions: [ + { label: 'Day 1 (15 May)', attendancePct: 98, spDelta: 10 }, + { label: 'Day 2 (16 May)', attendancePct: 95, spDelta: 10 }, + { label: 'Day 3 (19 May)', attendancePct: 92, spDelta: 10 }, + { label: 'Day 4 (20 May)', attendancePct: 96, spDelta: 10 }, + { label: 'Day 5 (21 May)', attendancePct: 91, spDelta: 10 }, + { label: 'Day 6 (22 May)', attendancePct: 94, spDelta: 10 } + ] + }, + { + name: 'Rahul Verma', email: 'rahul@test.com', + status: 'active', totalSp: 140, sessions: [ + { label: 'Day 1 (15 May)', attendancePct: 85, spDelta: 5 }, + { label: 'Day 2 (16 May)', attendancePct: 80, spDelta: 5 }, + { label: 'Day 3 (19 May)', attendancePct: 78, spDelta: 5 }, + { label: 'Day 4 (20 May)', attendancePct: 82, spDelta: 5 }, + { label: 'Day 5 (21 May)', attendancePct: 76, spDelta: 5 }, + { label: 'Day 6 (22 May)', attendancePct: 79, spDelta: 5 } + ] + }, + { + name: 'Priya Patel', email: 'priya@test.com', + status: 'active', totalSp: 118, sessions: [ + { label: 'Day 1 (15 May)', attendancePct: 92, spDelta: 10 }, + { label: 'Day 2 (16 May)', attendancePct: 85, spDelta: 5 }, + { label: 'Day 3 (19 May)', attendancePct: 70, spDelta: 3 }, + { label: 'Day 4 (20 May)', attendancePct: 55, spDelta: 3 }, + { label: 'Day 5 (21 May)', attendancePct: 40, spDelta: 0 }, + { label: 'Day 6 (22 May)', attendancePct: 25, spDelta: 0 } + ] + }, + { + name: 'Arjun Nair', email: 'arjun@test.com', + status: 'active', totalSp: 126, sessions: [ + { label: 'Day 1 (15 May)', attendancePct: 40, spDelta: 0 }, + { label: 'Day 2 (16 May)', attendancePct: 50, spDelta: 3 }, + { label: 'Day 3 (19 May)', attendancePct: 45, spDelta: 0 }, + { label: 'Day 4 (20 May)', attendancePct: 78, spDelta: 5 }, + { label: 'Day 5 (21 May)', attendancePct: 85, spDelta: 5 }, + { label: 'Day 6 (22 May)', attendancePct: 92, spDelta: 10 } + ] + } +]; + +const sessionDefs = [ + { label: 'Day 1 (15 May)', date: new Date('2026-05-15'), endDateTime: new Date('2026-05-15T12:00Z'), totalMinutes: 120 }, + { label: 'Day 2 (16 May)', date: new Date('2026-05-16'), endDateTime: new Date('2026-05-16T12:00Z'), totalMinutes: 120 }, + { label: 'Day 3 (19 May)', date: new Date('2026-05-19'), endDateTime: new Date('2026-05-19T12:00Z'), totalMinutes: 90 }, + { label: 'Day 4 (20 May)', date: new Date('2026-05-20'), endDateTime: new Date('2026-05-20T12:00Z'), totalMinutes: 120 }, + { label: 'Day 5 (21 May)', date: new Date('2026-05-21'), endDateTime: new Date('2026-05-21T12:00Z'), totalMinutes: 80 }, + { label: 'Day 6 (22 May)', date: new Date('2026-05-22'), endDateTime: new Date('2026-05-22T12:00Z'), totalMinutes: 240 } +]; + +async function seed() { + await mongoose.connect(MONGO_URI); + console.log('Connected to MongoDB'); + + await Promise.all([ + Student.deleteMany({}), + Session.deleteMany({}), + AttendanceRecord.deleteMany({}), + SPTransaction.deleteMany({}) + ]); + console.log('Cleared existing data'); + + const sessions = await Session.insertMany(sessionDefs); + console.log(`Inserted ${sessions.length} sessions`); + + for (const studentData of students) { + const student = await Student.create({ + name: studentData.name, + email: studentData.email, + internshipStartDate: new Date('2026-05-15'), + internshipEndDate: new Date('2026-08-15'), + status: 'active', + totalSp: studentData.totalSp + }); + + let balance = 100; + await SPTransaction.create({ + email: student.email, studentId: student._id, + category: 'initial', sessionLabel: '', + deltaMode: 'absolute', deltaValue: 100, appliedDelta: 100, + balanceAfter: 100, reason: 'Initial SP credit', + dateTime: new Date('2026-05-15') + }); + + for (const sess of studentData.sessions) { + balance += sess.spDelta; + const attSess = sessions.find(s => s.label === sess.label); + await AttendanceRecord.create({ + email: student.email, studentId: student._id, + sessionLabel: sess.label, + attendedMinutes: Math.round(sess.attendancePct / 100 * (attSess?.totalMinutes || 120)), + totalSessionMinutes: attSess?.totalMinutes || 120, + attendancePercentage: sess.attendancePct, + qualified: sess.attendancePct >= 75 + }); + await SPTransaction.create({ + email: student.email, studentId: student._id, + category: 'attendance', sessionLabel: sess.label, + deltaMode: 'absolute', deltaValue: sess.spDelta, appliedDelta: sess.spDelta, + balanceAfter: balance, reason: `Attendance SP for ${sess.label}`, + dateTime: attSess?.endDateTime || new Date('2026-05-15') + }); + } + console.log(` ${studentData.name.padEnd(16)} (${studentData.email}) — totalSp: ${studentData.totalSp}, sessions: ${studentData.sessions.length}`); + } + + console.log('\nDone! 4 demo students seeded.'); + console.log('Search at http://localhost:5290 by name or email.'); + await mongoose.disconnect(); +} + +seed().catch(err => { console.error(err); process.exit(1); }); diff --git a/server/engagement/test-classifier.js b/server/engagement/test-classifier.js new file mode 100644 index 0000000..b5f444c --- /dev/null +++ b/server/engagement/test-classifier.js @@ -0,0 +1,46 @@ +import mongoose from 'mongoose'; +import { MONGO_URI } from '../config.js'; +import { fetchStudentEngagementData } from './fetchData.js'; +import { classifyBand } from './classifyBand.js'; + +const emails = [ + 'ananya@test.com', + 'rahul@test.com', + 'priya@test.com', + 'arjun@test.com' +]; + +function printWindow(data) { + for (const s of data) { + const att = s.attendancePct !== null ? String(s.attendancePct).padStart(5) + '%' : ' N/A '; + const sp = String(s.spDelta).padStart(3); + console.log(` ${s.label.padEnd(20)}| ${att} | ${sp}`); + } +} + +async function main() { + await mongoose.connect(MONGO_URI); + + for (const email of emails) { + const data = await fetchStudentEngagementData(email); + const result = classifyBand(data.current, data.previous); + + console.log(`\n${'='.repeat(60)}`); + console.log(`${email}`); + console.log(`${'='.repeat(60)}`); + console.log(`\nPrevious window (${data.previous.length} sessions):`); + printWindow(data.previous); + console.log(`\nCurrent window (${data.current.length} sessions):`); + printWindow(data.current); + console.log(`\n → Band: ${result.band}`); + console.log(` → Reason: ${result.reason}`); + if (result.stats) { + console.log(` → Avg Attendance: ${result.stats.avgAttendancePct}%`); + console.log(` → Avg SP/session: ${result.stats.avgSpPerSession}`); + } + } + + await mongoose.disconnect(); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/server/engagement/test-fetch.js b/server/engagement/test-fetch.js new file mode 100644 index 0000000..2cd0330 --- /dev/null +++ b/server/engagement/test-fetch.js @@ -0,0 +1,67 @@ +import { ROLLING_WINDOW_SIZE } from './config.js'; + +function simulateFetch(email) { + const sessions = [ + { label: 'Day 1 (15 May)', date: new Date('2026-05-15T12:00Z'), totalMinutes: 120 }, + { label: 'Day 2 (16 May)', date: new Date('2026-05-16T12:00Z'), totalMinutes: 120 }, + { label: 'Day 3 (19 May)', date: new Date('2026-05-19T12:00Z'), totalMinutes: 90 }, + { label: 'Day 4 (20 May)', date: new Date('2026-05-20T12:00Z'), totalMinutes: 120 }, + { label: 'Day 5 (21 May)', date: new Date('2026-05-21T12:00Z'), totalMinutes: 80 }, + { label: 'Day 6 (22 May)', date: new Date('2026-05-22T12:00Z'), totalMinutes: 240 } + ]; + + const attendance = { + 'Day 1 (15 May)': 95, + 'Day 2 (16 May)': 88, + 'Day 3 (19 May)': 72, + 'Day 4 (20 May)': 65, + 'Day 5 (21 May)': 45, + 'Day 6 (22 May)': 30 + }; + + const spDeltas = { + 'Day 1 (15 May)': 10, + 'Day 2 (16 May)': 10, + 'Day 3 (19 May)': 5, + 'Day 4 (20 May)': 5, + 'Day 5 (21 May)': 3, + 'Day 6 (22 May)': 0 + }; + + const windowed = sessions.map(s => ({ + label: s.label, + date: s.date, + totalMinutes: s.totalMinutes, + attendancePct: attendance[s.label] ?? null, + spDelta: spDeltas[s.label] ?? 0 + })); + + const n = ROLLING_WINDOW_SIZE; + const current = windowed.slice(-n); + const previous = windowed.length > n ? windowed.slice(-n * 2, -n) : []; + + return { current, previous, all: windowed }; +} + +function printWindow(label, data) { + console.log(`\n=== ${label} (${data.length} sessions, window size=${ROLLING_WINDOW_SIZE}) ===`); + console.log('Session | Attend % | SP Delta'); + console.log('-------------------------|----------|---------'); + for (const s of data) { + const att = s.attendancePct !== null ? String(s.attendancePct).padStart(5) + '%' : ' N/A '; + const sp = String(s.spDelta).padStart(3); + console.log(`${s.label.padEnd(25)}| ${att} | ${sp}`); + } +} + +console.log('Fetching engagement data for student@example.com...\n'); + +const data = simulateFetch('student@example.com'); + +printWindow('PREVIOUS WINDOW', data.previous); +printWindow('CURRENT WINDOW', data.current); + +console.log('\n--- Summary ---'); +console.log(`Total sessions available: ${data.all.length}`); +console.log(`Previous window covers: ${data.previous.map(s => s.label).join(', ') || '(none)'}`); +console.log(`Current window covers: ${data.current.map(s => s.label).join(', ')}`); diff --git a/server/engagement/verify-chunk8.js b/server/engagement/verify-chunk8.js new file mode 100644 index 0000000..d514432 --- /dev/null +++ b/server/engagement/verify-chunk8.js @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; +import { MONGO_URI } from '../config.js'; +import { fetchStudentEngagementData } from './fetchData.js'; +import { classifyBand } from './classifyBand.js'; +import Student from '../models/Student.js'; + +async function verify() { + await mongoose.connect(MONGO_URI); + const students = await Student.find({ status: 'active' }).lean(); + + console.log('=== VERIFICATION: Engagement Classification ===\n'); + + let ok = 0; + for (const s of students) { + const data = await fetchStudentEngagementData(s.email); + const r1 = classifyBand(data.current, data.previous); + const r2 = classifyBand(data.current, data.previous); + const idempotent = r1.band === r2.band; + + console.log(` ${s.email.padEnd(22)} \u2192 ${r1.band.padEnd(14)} | idempotent: ${idempotent ? 'YES' : 'NO'}`); + console.log(` Reason: ${r1.reason}`); + console.log(` Avg Att: ${r1.stats?.avgAttendancePct || 'N/A'}%, Avg SP/session: ${r1.stats?.avgSpPerSession || 'N/A'}`); + if (idempotent) ok++; + } + + console.log(`\n \u2713 Idempotent: ${ok}/${students.length}`); + + // Leaderboard check — confirm no side effects + const lb = await Student.find({ status: 'active' }).sort({ totalSp: -1, name: 1 }).lean(); + console.log('\n=== LEADERBOARD (unchanged by engagement calls) ==='); + console.log(' Rank | Name | Total SP'); + console.log(' -----|-----------------------|---------'); + lb.forEach((s, i) => { + console.log(` ${String(i + 1).padStart(4)} | ${s.name.padEnd(22)} | ${s.totalSp}`); + }); + + await mongoose.disconnect(); +} + +verify().catch(err => { console.error(err); process.exit(1); }); diff --git a/server/journey/computeJourney.js b/server/journey/computeJourney.js new file mode 100644 index 0000000..1b13b46 --- /dev/null +++ b/server/journey/computeJourney.js @@ -0,0 +1,92 @@ +import Session from '../models/Session.js'; +import AttendanceRecord from '../models/AttendanceRecord.js'; +import PollRecord from '../models/PollRecord.js'; +import { resolveDateRange } from './dateRange.js'; +import { getTarget } from './targets.js'; + +export async function computeJourney(email, student, window = 'weekly') { + const range = resolveDateRange(window, student); + if (!range) return null; + + const target = await getTarget(window); + if (!target) return null; + + const sessions = await Session.find({ + endDateTime: { $gte: range.start, $lte: range.end } + }).sort({ endDateTime: 1 }).lean(); + + const sessionLabels = sessions.map(s => s.label); + + const [attendanceRecords, pollRecords] = await Promise.all([ + AttendanceRecord.find({ email, sessionLabel: { $in: sessionLabels } }).lean(), + PollRecord.find({ email, sessionLabel: { $in: sessionLabels } }).lean() + ]); + + const totalSessions = sessions.length; + const qualifiedSessions = attendanceRecords.filter(a => a.qualified).length; + const attendancePct = totalSessions ? Math.round((qualifiedSessions / totalSessions) * 100) : 0; + + const totalPollQ = pollRecords.reduce((s, p) => s + p.totalQuestions, 0); + const attemptedPollQ = pollRecords.reduce((s, p) => s + p.attemptedQuestions, 0); + const pollPct = totalPollQ ? Math.round((attemptedPollQ / totalPollQ) * 100) : 0; + + const wAtt = (target.attendanceWeight || 50) / 100; + const wPoll = (target.pollWeight || 50) / 100; + const overallPct = Math.round((attendancePct * wAtt) + (pollPct * wPoll)); + + const elapsedMs = Date.now() - range.start.getTime(); + const totalMs = range.end.getTime() - range.start.getTime(); + const elapsedRatio = totalMs > 0 ? Math.min(1, Math.max(0, elapsedMs / totalMs)) : 0; + const checkpointsReached = Math.min(target.checkpointCount, Math.max(0, Math.floor(elapsedRatio * target.checkpointCount))); + + const checkpoints = []; + for (let i = 1; i <= target.checkpointCount; i++) { + const pct = Math.round((i / target.checkpointCount) * 100); + checkpoints.push({ + checkpoint: i, + label: `${pct}%`, + reached: i <= checkpointsReached, + pctThrough: pct + }); + } + + // Attendance breakdown per session + const sessionDetails = sessions.map(s => { + const att = attendanceRecords.find(a => a.sessionLabel === s.label); + const poll = pollRecords.find(p => p.sessionLabel === s.label); + return { + label: s.label, + date: s.endDateTime, + attendedMinutes: att?.attendedMinutes || 0, + totalMinutes: s.totalMinutes, + qualified: att?.qualified || false, + attendancePct: att?.attendancePercentage || 0, + pollAttempted: poll?.attemptedQuestions || 0, + pollTotal: poll?.totalQuestions || 0 + }; + }); + + return { + window, + range: { start: range.start, end: range.end, label: range.label }, + target: { + label: target.label, + checkpointCount: target.checkpointCount, + attendanceTargetPct: target.attendanceTargetPct, + pollTargetPct: target.pollTargetPct + }, + progress: { + attendancePct, + pollPct, + overallPct, + attendanceQualified: qualifiedSessions, + attendanceTotal: totalSessions, + pollAttempted: attemptedPollQ, + pollTotal: totalPollQ, + checkpointsReached, + totalCheckpoints: target.checkpointCount + }, + checkpoints, + sessions: sessionDetails + }; +} diff --git a/server/journey/dateRange.js b/server/journey/dateRange.js new file mode 100644 index 0000000..b81497d --- /dev/null +++ b/server/journey/dateRange.js @@ -0,0 +1,30 @@ +export function resolveDateRange(window, student) { + const now = new Date(); + + switch (window) { + case 'weekly': { + const day = now.getDay(); + const diff = day === 0 ? 6 : day - 1; + const monday = new Date(now); + monday.setDate(now.getDate() - diff); + monday.setHours(0, 0, 0, 0); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + sunday.setHours(23, 59, 59, 999); + return { start: monday, end: sunday, label: 'This Week' }; + } + case 'monthly': { + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + return { start, end, label: `${months[now.getMonth()]} ${now.getFullYear()}` }; + } + case 'tenure': { + const start = student.internshipStartDate ? new Date(student.internshipStartDate) : new Date('2026-05-15'); + const end = student.internshipEndDate ? new Date(student.internshipEndDate) : new Date('2026-08-15'); + return { start, end, label: 'Full Internship' }; + } + default: + return null; + } +} diff --git a/server/journey/targets.js b/server/journey/targets.js new file mode 100644 index 0000000..0948667 --- /dev/null +++ b/server/journey/targets.js @@ -0,0 +1,30 @@ +import JourneyTarget from '../models/JourneyTarget.js'; + +export const DEFAULT_TARGETS = { + weekly: { window: 'weekly', label: 'This Week', checkpointCount: 5, attendanceTargetPct: 80, pollTargetPct: 75, attendanceWeight: 50, pollWeight: 50 }, + monthly: { window: 'monthly', label: 'This Month', checkpointCount: 4, attendanceTargetPct: 85, pollTargetPct: 80, attendanceWeight: 50, pollWeight: 50 }, + tenure: { window: 'tenure', label: 'Full Internship', checkpointCount: 8, attendanceTargetPct: 75, pollTargetPct: 70, attendanceWeight: 50, pollWeight: 50 } +}; + +export async function getTarget(window) { + const db = await JourneyTarget.findOne({ window, active: true }).lean(); + return db || DEFAULT_TARGETS[window] || null; +} + +export async function getAllTargets() { + const db = await JourneyTarget.find({ active: true }).lean(); + const merged = {}; + for (const key of Object.keys(DEFAULT_TARGETS)) { + const dbEntry = db.find(t => t.window === key); + merged[key] = dbEntry || DEFAULT_TARGETS[key]; + } + return merged; +} + +export async function upsertTarget(window, data) { + return JourneyTarget.findOneAndUpdate( + { window }, + { $set: { ...data, window } }, + { upsert: true, new: true, lean: true } + ); +} diff --git a/server/models/JourneyTarget.js b/server/models/JourneyTarget.js new file mode 100644 index 0000000..9cec021 --- /dev/null +++ b/server/models/JourneyTarget.js @@ -0,0 +1,14 @@ +import mongoose from 'mongoose'; + +const journeyTargetSchema = new mongoose.Schema({ + window: { type: String, enum: ['weekly', 'monthly', 'tenure'], required: true, unique: true }, + label: { type: String, required: true }, + checkpointCount: { type: Number, required: true, min: 1, max: 10 }, + attendanceTargetPct: { type: Number, required: true, min: 0, max: 100 }, + pollTargetPct: { type: Number, required: true, min: 0, max: 100 }, + attendanceWeight: { type: Number, default: 50, min: 0, max: 100 }, + pollWeight: { type: Number, default: 50, min: 0, max: 100 }, + active: { type: Boolean, default: true } +}, { timestamps: true }); + +export default mongoose.model('JourneyTarget', journeyTargetSchema); diff --git a/server/routes/engagement.js b/server/routes/engagement.js new file mode 100644 index 0000000..99aeaf2 --- /dev/null +++ b/server/routes/engagement.js @@ -0,0 +1,46 @@ +import { Router } from 'express'; +import { fetchStudentEngagementData } from '../engagement/fetchData.js'; +import { classifyBand } from '../engagement/classifyBand.js'; +import Student from '../models/Student.js'; + +const router = Router(); + +router.get('/engagement/:email', async (req, res) => { + try { + const email = String(req.params.email || '').trim().toLowerCase(); + if (!email) return res.status(400).json({ error: 'Email is required' }); + + const student = await Student.findOne({ email }).lean(); + if (!student) return res.status(404).json({ error: 'Student not found' }); + + const data = await fetchStudentEngagementData(email); + const result = classifyBand(data.current, data.previous); + + res.json({ + email, + name: student.name, + totalSp: student.totalSp, + band: result.band, + reason: result.reason, + stats: result.stats, + windows: { + windowSize: data.current.length, + current: data.current.map(s => ({ + label: s.label, + attendancePct: s.attendancePct, + spDelta: s.spDelta + })), + previous: data.previous.length > 0 ? data.previous.map(s => ({ + label: s.label, + attendancePct: s.attendancePct, + spDelta: s.spDelta + })) : null + } + }); + } catch (err) { + console.error('Engagement API error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/server/routes/journey.js b/server/routes/journey.js new file mode 100644 index 0000000..8cdbb76 --- /dev/null +++ b/server/routes/journey.js @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { computeJourney } from '../journey/computeJourney.js'; +import Student from '../models/Student.js'; + +const router = Router(); + +router.get('/journey/:email', async (req, res) => { + try { + const email = String(req.params.email || '').trim().toLowerCase(); + const window = ['weekly', 'monthly', 'tenure'].includes(req.query.window) + ? req.query.window : 'weekly'; + if (!email) return res.status(400).json({ error: 'Email is required' }); + const student = await Student.findOne({ email }).lean(); + if (!student) return res.status(404).json({ error: 'Student not found' }); + const result = await computeJourney(email, student, window); + if (!result) return res.status(400).json({ error: 'Invalid window' }); + res.json(result); + } catch (err) { + console.error('Journey API error:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/server/server.js b/server/server.js index 53579d2..f5bab46 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,9 @@ import PollRecord from './models/PollRecord.js'; import SPTransaction from './models/SPTransaction.js'; import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; +import engagementRouter from './routes/engagement.js'; +import journeyRouter from './routes/journey.js'; +import { getAllTargets, upsertTarget } from './journey/targets.js'; import Commitment from './models/Commitment.js'; import { isVibeEligible, buildVibeState, validateBet, settleBetDemo, applySpDelta, courseByKey } from './services/vibe.js'; import { buildStandupState, placeStandup, settleStandupDemo } from './services/standup.js'; @@ -729,12 +732,56 @@ api.get('/admin/analytics', adminGuard, async (_req, res) => { }); }); +api.get('/admin/engagement/report', adminGuard, async (req, res) => { + const bandFilter = String(req.query.band || '').trim(); + const students = await Student.find({ status: 'active' }).lean(); + const results = []; + for (const student of students) { + try { + const data = await fetchStudentEngagementData(student.email); + const result = classifyBand(data.current, data.previous); + results.push({ email: student.email, name: student.name, totalSp: student.totalSp, band: result.band, reason: result.reason, stats: result.stats }); + } catch { /* skip students with no data */ } + } + const groups = {}; + for (const r of results) { + if (!groups[r.band]) groups[r.band] = []; + groups[r.band].push(r); + } + if (bandFilter) { + const filtered = groups[bandFilter] || []; + return res.json({ band: bandFilter, count: filtered.length, students: filtered }); + } + const summary = {}; + for (const [band, list] of Object.entries(groups)) summary[band] = { count: list.length }; + res.json({ summary, total: results.length, groups }); +}); + +api.get('/admin/journey/targets', adminGuard, async (_req, res) => { + try { res.json(await getAllTargets()); } + catch (err) { res.status(500).json({ error: err.message }); } +}); + +api.put('/admin/journey/targets/:window', adminGuard, async (req, res) => { + try { + if (!['weekly', 'monthly', 'tenure'].includes(req.params.window)) { + return res.status(400).json({ error: 'Invalid window' }); + } + const updated = await upsertTarget(req.params.window, req.body); + res.json(updated); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + function last24Hours(now) { return new Date(now.getTime() - 24 * 60 * 60 * 1000); } app.use('/api', api); app.use('/spurti/api', api); +app.use('/api', engagementRouter); +app.use('/spurti/api', engagementRouter); +app.use('/api', journeyRouter); +app.use('/spurti/api', journeyRouter); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist));