@@ -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));