From 1aa9af53a19ffffdbf51b63ec7fbd4e36178d1db Mon Sep 17 00:00:00 2001 From: Vedhiga V B Date: Wed, 15 Jul 2026 18:33:50 +0530 Subject: [PATCH 1/3] Add engagement classification system (Chunks 1-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/engagement/config.js — constants, thresholds, band labels - server/engagement/fetchData.js — MongoDB fetch + window split - server/engagement/classifyBand.js — pure band classifier + Recovery detection - server/routes/engagement.js — GET /api/engagement/:email endpoint - server/server.js — mount engagement router + GET /api/admin/engagement/report - CONTEXT.md — document endpoints and classification design --- CONTEXT.md | 26 ++++++ server/engagement/classifyBand.js | 74 ++++++++++++++++ server/engagement/config.js | 46 ++++++++++ server/engagement/fetchData.js | 37 ++++++++ server/engagement/seed-demo.js | 125 +++++++++++++++++++++++++++ server/engagement/test-classifier.js | 46 ++++++++++ server/engagement/test-fetch.js | 67 ++++++++++++++ server/routes/engagement.js | 46 ++++++++++ server/server.js | 28 ++++++ 9 files changed, 495 insertions(+) create mode 100644 server/engagement/classifyBand.js create mode 100644 server/engagement/config.js create mode 100644 server/engagement/fetchData.js create mode 100644 server/engagement/seed-demo.js create mode 100644 server/engagement/test-classifier.js create mode 100644 server/engagement/test-fetch.js create mode 100644 server/routes/engagement.js diff --git a/CONTEXT.md b/CONTEXT.md index 85352b1..a0a43ce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -136,6 +136,32 @@ 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` + ## 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/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/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/server.js b/server/server.js index 11f6e4f..75abbd9 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,7 @@ 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'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -613,12 +614,39 @@ 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 }); +}); + 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); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist)); From f78fee64553d21407d55531e7406b3f71fa0a280 Mon Sep 17 00:00:00 2001 From: Vedhiga V B Date: Thu, 16 Jul 2026 03:43:16 +0530 Subject: [PATCH 2/3] Add engagement band spectrum UI with Minecraft-style design --- client/index.html | 1 + .../components/engagement/AdminBandGrid.jsx | 115 ++++++ .../src/components/engagement/BlockIcon.jsx | 38 ++ .../components/engagement/StudentBandCard.jsx | 63 ++++ client/src/main.jsx | 7 +- client/src/styles/minecraft-tokens.css | 351 ++++++++++++++++++ server/engagement/verify-chunk8.js | 40 ++ 7 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 client/src/components/engagement/AdminBandGrid.jsx create mode 100644 client/src/components/engagement/BlockIcon.jsx create mode 100644 client/src/components/engagement/StudentBandCard.jsx create mode 100644 client/src/styles/minecraft-tokens.css create mode 100644 server/engagement/verify-chunk8.js 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/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/main.jsx b/client/src/main.jsx index cebf2cc..5e8423f 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,6 +1,9 @@ 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'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -278,6 +281,7 @@ function StudentView({ profile, onBack }) {
SP{student.totalSp}Rank {student.rank} of {student.cohortSize}
+ {tab === 'bank' && } @@ -573,7 +577,7 @@ function AdminView({ admin, auth, onBack }) {

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' && (
@@ -592,6 +596,7 @@ function AdminView({ admin, auth, onBack }) { {tab === 'attendance' && } {tab === 'live' && } {tab === 'analytics' && } + {tab === 'engagement' && } {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..162d7ed --- /dev/null +++ b/client/src/styles/minecraft-tokens.css @@ -0,0 +1,351 @@ +: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; +} 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); }); From f456f3be1a6993aa755365d449e2e6a206aa94a4 Mon Sep 17 00:00:00 2001 From: Vedhiga V B Date: Thu, 30 Jul 2026 23:41:39 +0530 Subject: [PATCH 3/3] Revert "Resolve merge conflicts with upstream/main" This reverts commit aa58cce017579b031b52149d8d653ac1893a2d9b, reversing changes made to f78fee64553d21407d55531e7406b3f71fa0a280. --- CONTEXT.md | 25 +- client/src/main.jsx | 744 +++------------------------- client/src/styles.css | 176 +------ pipeline/sp-rubric-build-mirror.cjs | 214 +------- pipeline/sp-rubric-build.js | 36 +- pipeline/spandan-poll-fetch.cjs | 110 ---- pipeline/sync-poll-records.js | 25 +- server/models/Commitment.js | 41 -- server/models/JourneyPlan.js | 14 - server/models/JourneyProgress.js | 19 - server/models/SPTransaction.js | 2 +- server/models/SpaProgress.js | 31 -- server/models/Student.js | 5 +- server/models/TrajectorySnapshot.js | 18 - server/models/VibeProgress.js | 16 - server/scripts/buildTrajectories.js | 15 - server/scripts/seedVibeDummy.js | 155 ------ server/server.js | 128 +---- server/services/journey.js | 182 ------- server/services/spa.js | 61 --- server/services/standup.js | 114 ----- server/services/trajectory.js | 100 ---- server/services/vibe.js | 149 ------ 23 files changed, 100 insertions(+), 2280 deletions(-) delete mode 100644 pipeline/spandan-poll-fetch.cjs delete mode 100644 server/models/Commitment.js delete mode 100644 server/models/JourneyPlan.js delete mode 100644 server/models/JourneyProgress.js delete mode 100644 server/models/SpaProgress.js delete mode 100644 server/models/TrajectorySnapshot.js delete mode 100644 server/models/VibeProgress.js delete mode 100644 server/scripts/buildTrajectories.js delete mode 100644 server/scripts/seedVibeDummy.js delete mode 100644 server/services/journey.js delete mode 100644 server/services/spa.js delete mode 100644 server/services/standup.js delete mode 100644 server/services/trajectory.js delete mode 100644 server/services/vibe.js diff --git a/CONTEXT.md b/CONTEXT.md index 708e48f..a0a43ce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -107,15 +107,9 @@ which hold the retired CSV/±5 logic). See `pipeline/README.md` for detail. - **Initial:** +100 to every *started intern* on their official start date. Future-start interns are zeroed; non-intern roster entries are set aside. -- **Attendance (A):** presence clipped to the official window, `pct = clipped / - window`, then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**. - - **Before 2026-07-16 (morning standup):** window `[09:05 IST, min(first-instance-end, 11:00 IST)]`. - - **From 2026-07-16 (standup moved to evening):** window `[20:05 IST, min(picked-mtg-end, 21:00 IST)]` - and the scored meeting is the mandatory meeting with the **largest overlap** - of that evening window (not just the earliest-starting one — the all-day - persistent room must not steal the slot). Cutover + times are constants at - the top of `sp-rubric-build-mirror.cjs`: `EVENING_CUTOVER`, - `EVENING_WSTART_IST`, `EVENING_WEND_IST`. Change these if the timing shifts again. +- **Attendance (A):** presence clipped to the official window + `[09:05 IST, min(first-instance-end, 11:00 IST)]`; `pct = clipped / window`, + then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**. - **Poll (B):** `pct = answered / totalQuestions`, same band ladder (10/5/3/0). - **Grace day 2026-06-06:** 1-min join = full attendance + full poll. - **Chat / discretionary:** admin-reviewed via ChatSPReview in the web app @@ -200,19 +194,6 @@ Code: `getSamagamaUser` / `studentEmailFromRequest` in `server/server.js`. - **To verify new ingestion:** After running `ingestSession`, check that: (a) new session appears in `sessions` collection, (b) transaction count increases, (c) for a sample student, balance in `sptransactions` matches their `totalSp` in `students` table, (d) leaderboard API reflects updated SP ## Known Bugs / Notes -- **2026-07-16 standup moved morning → evening (attendance window fix).** Students - flagged that the 16 Jul evening standup (~60 min) credited "115 min". Cause was - NOT double-counting: the scorer clipped presence to the fixed **09:05–11:00 IST - (=115 min) morning window**, which no longer matched the standup. The persistent - Zoom room `95674128668` ("Evening Standup") stays open all day, so it satisfied - the old morning window. Fix: added an evening-window cutover (see SP Calculation - section) → from 16 Jul the window is **20:05–21:00 IST (55 min)** and the scorer - picks the max-overlap meeting. Re-scored + APPLIED 2026-07-17 09:17Z - (backup `sp-runs/sp_backup_mirror_2026-07-17T0917Z`; script backup - `pipeline/sp-rubric-build-mirror.cjs.bak.20260717T091026Z`). Impact on 16 Jul: - 493 students ↑ (mostly 0→+10, real evening attendees who'd been under-credited), - 35 ↓ (incl. ~20 who only idled in the morning room, 10→0), 204 unchanged. - Dates before the cutover use the identical old code path (no historical change). - `deltaMode` validator error: schema expects `'absolute' | 'percentage'`. Using `'percent'` (singular) causes validation failure. Fixed in code — only affects legacy transactions created before the fix (May 26 restart). - **Percentage SP support:** When a chat SP review is accepted with `% SP` (e.g. +10% SP), `deltaMode` is set to `'percentage'`, `deltaValue` holds the percent (e.g. 10), and `appliedDelta` is computed at accept time as `round(currentBalance * deltaValue / 100)`. This works correctly. diff --git a/client/src/main.jsx b/client/src/main.jsx index e0a6e54..5e8423f 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -87,13 +87,6 @@ function App() { completedKey="poll2Completed" onDone={() => setProfile(prev => ({ ...prev, student: { ...prev.student, poll2Completed: true } }))} /> - setProfile(prev => ({ ...prev, student: { ...prev.student, poll3Completed: true } }))} - /> ); } @@ -274,9 +267,9 @@ function SearchModal({ onClose, onStudent }) { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); - const [commitPhase, setCommitPhase] = useState('vibe'); const { student } = profile; - const goToCommitment = ph => { setCommitPhase(ph); setTab('vibe'); }; + const badges = useMemo(() => buildBadges(profile), [profile]); + const nextActions = useMemo(() => buildNextActions(profile), [profile]); return (
@@ -289,103 +282,15 @@ function StudentView({ profile, onBack }) {
- - + + {tab === 'bank' && } - {tab === 'journey' && } - {tab === 'vibe' && student.eligibleForVibeGoals && } - {tab === 'spa' && } + {tab === 'polls' && } {tab === 'leaderboard' && }
); } -// SPA → SP (display only). SP is scored + credited by the pipeline rubric -// (+5 per validated question learned, +8 per validated peer taught, capped 50/30, -// minus a one-time audit/fraud penalty) and lands in the SP Bank automatically. -// This tab just reads the rubric's `spaprogresses` summary. Universal across cohorts. -function SpaModule({ student }) { - const email = student.email; - const [data, setData] = useState(null); - - useEffect(() => { - (async () => { - const r = await fetch(`${API}/spa/state?email=${encodeURIComponent(email)}`); - setData(await r.json()); - })(); - }, [email]); - - if (!data) return
Loading your SPA points…
; - if (!data.hasActivity) return ( -
-

SPA — Peer Teaching Points

-

No validated SPA endorsements on record yet for {data.activity}. Learn a question and get endorsed, or endorse a peer — SP lands in your SP Bank automatically as each is validated.

-
- ); - - const { learn, teach, penalty, creditedSp, maxSp, config } = data; - - return ( -
-
-

SPA — Peer Teaching Points

-

For {data.activity}, SP is credited to your SP Bank automatically as each endorsement is validated — +{config.learnUnit} SP per question you learn, +{config.teachUnit} SP per peer you teach. No claiming needed.

-
- -
- {/* Track A — Learning */} -
-
A

Learning

+{learn.sp} SP
-

Questions you were validly endorsed on

-
-
{learn.validated}validated
-
{learn.credited}credited
-
×{learn.unit}SP each
-
- {learn.validated > learn.cap &&
Capped at {learn.cap} — extra {learn.validated - learn.cap} not counted
} -
- - {/* Track B — Teaching */} -
-
B

Teaching

+{teach.sp} SP
-

Peers you validly endorsed

-
-
{teach.validated}validated
-
{teach.credited}credited
-
×{teach.unit}SP each
-
- {teach.validated > teach.cap &&
Capped at {teach.cap} — extra {teach.validated - teach.cap} not counted
} -
-
- -
-

SPA SP summary

- - - - - - {penalty.done && penalty.applied > 0 && ( - - - - - )} - -
Learning (Track A) — {learn.credited} × {config.learnUnit}+{learn.sp} SP
Teaching (Track B) — {teach.credited} × {config.teachUnit}+{teach.sp} SP
Total credited to SP Bank (max {maxSp})+{creditedSp} SP
{penalty.fraud ? '⚠️ Fraud penalty' : '⚠️ Audit-failure penalty'} — −{Math.round(penalty.rate * 100)}% of current SP{penalty.at ? ` on ${new Date(penalty.at).toLocaleDateString()}` : ''}−{penalty.applied} SP
-

- ✅ Auto-credited to your SP Bank — current balance {data.totalSp} SP. - {penalty.done && penalty.applied > 0 ? ' An integrity penalty was applied (see the debit row in your SP Bank).' : ''} -

-
-
- ); -} - function LevelStatus({ student }) { const tier = String(student.trophyLeague || 'Bronze').split(' ')[0].toLowerCase(); return ( @@ -446,98 +351,49 @@ function LeaderboardTabs({ overall = [], group = [], groupLabel }) { ); } -// SP trajectory modal — the student's weekly cumulative SP vs cohort + onboarding-group -// means (reference lines cached in TrajectorySnapshot; own line built live from the ledger). -function TrajectoryModal({ student, onClose }) { - const [data, setData] = useState(null); - useEffect(() => { - fetch(`${API}/trajectory/state?email=${encodeURIComponent(student.email)}`).then(r => r.json()).then(setData); - }, [student.email]); - - const series = data ? [ - { key: 'you', label: 'You', color: 'var(--primary)', points: data.you, width: 3, dots: true }, - { key: 'cohort', label: 'Cohort average', color: '#94a3b8', points: data.cohort, width: 2, dash: '5 4' }, - { key: 'group', label: data.groupLabel ? `Your group (${data.groupLabel})` : 'Your group', color: '#8b5cf6', points: data.group, width: 2 } - ].filter(s => s.points && s.points.length) : []; - - const weeks = data?.weeks || 10; - const yMax = Math.max(10, ...series.flatMap(s => s.points.map(p => p.sp))); - const W = 760, H = 400, padL = 52, padR = 18, padT = 18, padB = 42; - const plotW = W - padL - padR, plotH = H - padT - padB; - const sx = wk => padL + (weeks <= 1 ? 0 : (wk - 1) / (weeks - 1) * plotW); - const sy = sp => padT + (1 - sp / yMax) * plotH; - const yTicks = [0, 0.25, 0.5, 0.75, 1].map(f => Math.round(yMax * f)); - const xTicks = Array.from({ length: weeks }, (_, i) => i + 1); - +function StudentPulse({ profile, badges, nextActions }) { + const { student, cohort, attendance, polls, transactions } = profile; + const qualified = attendance.filter(a => a.qualified).length; + const pollAttempted = polls.reduce((sum, p) => sum + p.attemptedQuestions, 0); + const pollTotal = polls.reduce((sum, p) => sum + p.totalQuestions, 0); + const trend = transactions.map(tx => ({ label: tx.sessionLabel || 'Start', value: tx.balanceAfter })); return ( -
-
e.stopPropagation()}> -
-
-

Your trajectory

-

SP over your internship — you vs cohort

-
- +
+
+ Standing + Rank {student.rank} +

{cohort.pointsToTop50 === 0 ? 'You are in the Top 50.' : `${cohort.pointsToTop50} SP needed to enter Top 50.`}

+

{cohort.pointsToNextRank === 0 ? 'You are leading your comparison group.' : `${cohort.pointsToNextRank} SP needed for next rank.`}

+
+
+ Cohort comparison +
+ Your SP: {student.totalSp} + Cohort avg: {cohort.averageSp} + Top 50 cutoff: {cohort.top50Cutoff ?? '-'} + Top 10 cutoff: {cohort.top10Cutoff ?? '-'}
- {!data ?

Loading…

: series.length === 0 ? ( -

Not enough data yet — check back after your first week.

- ) : ( - <> -
- {series.map(s => {s.label})} -
-
- - {yTicks.map(v => ( - - - {v} - - ))} - {xTicks.map(w => W{w})} - Weeks since you joined - {series.map(s => ( - - `${sx(p.week)},${sy(p.sp)}`).join(' ')} - fill="none" stroke={s.color} strokeWidth={s.width} strokeDasharray={s.dash || ''} - strokeLinejoin="round" strokeLinecap="round" /> - {s.dots && s.points.map(p => )} - - ))} - -
-

Cumulative SP, aligned to each student's own join week so everyone is compared fairly regardless of start date.{data.computedAt ? ` Cohort lines updated ${new Date(data.computedAt).toLocaleDateString()}.` : ''}

- - )}
-
- ); -} - -function StudentPulse({ profile }) { - const { student, cohort, transactions } = profile; - const [showTraj, setShowTraj] = useState(false); - const trend = transactions.map(tx => ({ label: tx.sessionLabel || 'Start', value: tx.balanceAfter })); - return ( - <> -
-
- Standing - Rank {student.rank} -

{cohort.pointsToTop50 === 0 ? 'You are in the Top 50.' : `${cohort.pointsToTop50} SP to enter Top 50.`}

-
- Cohort avg: {cohort.averageSp} - Top 50: {cohort.top50Cutoff ?? '—'} - Top 10: {cohort.top10Cutoff ?? '—'} -
+
+ Session health +
+ {qualified}/{attendance.length} attendance qualified + {pollAttempted}/{pollTotal} polls attempted
- -
- {showTraj && setShowTraj(false)} />} - +
+
+ Badges +
{badges.map(badge => {badge})}
+
+
+ SP trend + +
+
+ What to do next +
    {nextActions.map(action =>
  • {action}
  • )}
+
+
); } @@ -555,45 +411,38 @@ function Sparkline({ points }) { ); } +function buildBadges(profile) { + const badges = []; + const qualifiedPct = profile.attendance.length ? profile.attendance.filter(a => a.qualified).length / profile.attendance.length : 0; + const pollAttempted = profile.polls.reduce((sum, p) => sum + p.attemptedQuestions, 0); + const pollTotal = profile.polls.reduce((sum, p) => sum + p.totalQuestions, 0); + if (profile.student.rank <= 50) badges.push('Top 50'); + if (qualifiedPct >= 0.75) badges.push('Consistent Attendee'); + if (pollTotal && pollAttempted / pollTotal >= 0.75) badges.push('Poll Champion'); + if (profile.student.totalSp >= profile.cohort.averageSp) badges.push('Above Average'); + return badges.length ? badges : ['Getting Started']; +} + +function buildNextActions(profile) { + const actions = []; + if (profile.cohort.pointsToTop50 > 0) actions.push(`Earn ${profile.cohort.pointsToTop50} more SP to enter Top 50.`); + if (profile.attendance.some(a => !a.qualified)) actions.push('Attend at least 75% of upcoming sessions to avoid attendance debit.'); + if (profile.polls.some(p => p.missedQuestions > 0)) actions.push('Attempt every poll question to avoid poll debit.'); + actions.push('Check your SP Bank after each session to understand every credit and debit.'); + return actions.slice(0, 4); +} + function Tabs({ tab, setTab, tabs }) { return ; } function SpBank({ transactions }) { - const [size, setSize] = useState(10); - // Server sends oldest→newest (sorted dateTime asc); show newest first. - const rows = useMemo(() => [...transactions].reverse(), [transactions]); - const shown = rows.slice(0, size); - const downloadCsv = () => { - const esc = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const lines = [['Date & time', 'Credit', 'Debit', 'Balance', 'Reason'].join(',')].concat( - rows.map(tx => [ - new Date(tx.dateTime).toLocaleString(), - tx.appliedDelta > 0 ? tx.appliedDelta : '', - tx.appliedDelta < 0 ? tx.appliedDelta : '', - tx.balanceAfter, tx.reason - ].map(esc).join(','))); - const url = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/csv' })); - const a = document.createElement('a'); - a.href = url; a.download = 'sp-bank-statement.csv'; a.click(); - URL.revokeObjectURL(url); - }; return (
-
-

SP Bank

-
- - -
-
+

SP Bank Statement

Date & timeCreditDebitBalanceReason
- {shown.map(tx => ( + {transactions.map(tx => (
{new Date(tx.dateTime).toLocaleString()} {tx.appliedDelta > 0 ? `+${tx.appliedDelta}` : ''} @@ -603,7 +452,6 @@ function SpBank({ transactions }) {
))}
-

Showing {Math.min(size, rows.length)} of {rows.length} — download CSV for the full statement.

); } @@ -661,464 +509,6 @@ function Leaderboard({ rows }) { ); } -const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : '—'; -const toInput = d => d ? new Date(d).toISOString().slice(0, 10) : ''; - -// The unified phase-by-phase progress + SP tab. Four phases: Standups, ViBe, SPA, -// Projects. Standups & ViBe show real SP; SPA & Projects are placeholders until the -// Samagama data (and their SP rule) land. Goal *staking* lives in the Commitments tab. -const NEXT_NUDGE = { standup: 'Next up: push your ViBe courses.', vibe: 'Next up: keep your SPA pace.', spa: 'Next up: ship your first project PR.', project: 'On track across the board — keep it up!' }; - -// Goal block that lives ON a phase card: set a target date (none/missed) → pace bar -// once active → "reached" when done. Unit-aware (min for standups, % for ViBe). A GOAL -// is a self-set target (no SP) — distinct from a COMMITMENT (staking SP, the Stake link). -function PhaseGoal({ phaseKey, field, goal, targetText, form, setForm, onSave }) { - const isPct = goal.unit === '%'; - const metric = isPct ? `${goal.progressPct}% done` : `${goal.current}/${goal.target} ${goal.unit} (${goal.progressPct}%)`; - const paceLeft = isPct - ? `${goal.remainingPct}% to go · ~${goal.perDay ?? '—'}%/day to stay on track` - : `${goal.remaining} ${goal.unit} to go · ~${goal.perDay ?? '—'} ${goal.unit}/${goal.perDayUnit || 'day'} to stay on track`; - - if (goal.status === 'achieved') { - return
🎯 Goal reached 🎉{NEXT_NUDGE[phaseKey]}
; - } - if (goal.status === 'active') { - return ( -
- {goal.pending ? ( - 🎯 Goal: by {fmtDate(goal.targetDate)} · {goal.daysLeft}d left · progress soon - ) : ( - <> - 🎯 Goal: {metric} · by {fmtDate(goal.targetDate)} · {goal.daysLeft}d left -
- {paceLeft} - - )} -
- ); - } - return ( -
- - 🎯 {goal.status === 'missed' ? `Goal missed — set a new date to ${targetText}` : `Set a target date to ${targetText}`} - -
- setForm({ ...form, [field]: e.target.value })} /> - -
- {goal.minDate && Earliest realistic: {fmtDate(goal.minDate)}{goal.paceHint ? ` · ${goal.paceHint}` : ''}} -
- ); -} - -function MyJourney({ student, goToCommitment, canCommit = false }) { - const email = student.email; - const [data, setData] = useState(null); - const [form, setForm] = useState({}); - const [showTraj, setShowTraj] = useState(false); - const [err, setErr] = useState(null); - - const load = async () => { - const r = await fetch(`${API}/journey/state?email=${encodeURIComponent(email)}`); - setData(await r.json()); - }; - useEffect(() => { load(); }, [email]); - - if (!data) return
Loading your journey…
; - if (!data.eligible) return
My Journey isn’t available for your cohort yet.
; - - const { standups, vibe, goals } = data; - - const saveTarget = async (field, value) => { - const r = await fetch(`${API}/journey/plan`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, [field]: value }) - }); - const j = await r.json(); - if (!r.ok) { setErr(j.error); return; } - setErr(null); setData(j); - }; - const gp = { form, setForm, onSave: saveTarget }; - - return ( -
-
-

My Journey

-

🎯 Goal = your own finish-date target; it tracks your pace, no SP.{canCommit && <>  🎲 Commitment = stake SP on a bet — the Stake SP link.}

- {err &&

{err}

} -
- -
- {/* Standups — continuous, no completion goal; commitment only */} -
-
1

Standups

+{standups.sp} SP
-

Zoom attendance + Spandan polls

-
-
{standups.zoomMinutes}Zoom minutes
-
{standups.sessionsAttended}sessions attended
-
{standups.pollsAttempted}/{standups.pollsTotal}polls attempted
-
-
- Attendance +{standups.spAttendance} - Polls +{standups.spPolls} -
- - {canCommit &&
} -
- - {/* ViBe — goal + commitment */} -
-
2

ViBe courses

{vibe.sp >= 0 ? '+' : ''}{vibe.sp} SP
-

{vibe.clearedCount}/{vibe.totalCourses} courses complete

-
- {vibe.ladder.map(l => ( -
- {l.cleared ? '✓' : `${l.pct}%`}{l.name} -
- ))} -
- {vibe.activeCommitment &&
🎲 Active commitment: +{vibe.activeCommitment.goalPct}%
} - - {canCommit &&
} -
- - {/* SPA — goal (date) works now; progress data + commitment coming soon */} -
-
3

SPA — Matrix Mystics

Data soon
-

53-problem set · progress data coming soon

- -
- - {/* Projects — goal (date) works now; progress data coming soon */} -
-
4

Projects

Data soon
-

Pull requests · progress data coming soon

- -
-
- -
-
-

Your SP trajectory

-

Your Spurti Points over time vs the cohort and your group.

-
- -
- - {showTraj && setShowTraj(false)} />} -
- ); -} - -function courseName(ladder, key) { const c = ladder.find(l => l.key === key); return c ? c.name : key; } -// net SP over the whole commitment: won -> win minus the debited stake; lost -> stake + penalty -function netFor(b) { return b.status === 'won' ? b.potentialWin - b.stake : -(b.stake + b.potentialLoss); } - -// The Commitments hub: one accordion card per phase. Every phase shares the same SP -// engine (stake debited → HIT wins it back multiplied / MISS loses a penalty); only -// the target metric differs. ViBe is live; the other three land one by one. -const COMMITMENT_TYPES = [ - { key: 'vibe', name: 'ViBe courses', blurb: 'ViBe commitments are temporarily on hold — we’re reconnecting the ViBe course-completion feed. They’ll be back up soon.', ready: false }, - { key: 'standup', name: 'Standups', blurb: 'Standup commitments are paused — standups have moved to YouTube Live and the attendance module is being reworked. They’ll return once the new attendance tracking is ready.', ready: false }, - { key: 'spa', name: 'SPA — Matrix Mystics', blurb: 'Pledge to solve N of the 53 problems by a date.', ready: false }, - { key: 'project', name: 'Projects', blurb: 'Pledge to raise / merge N pull requests by a date.', ready: false } -]; - -function Commitments({ student, initialPhase }) { - const [phase, setPhase] = useState(initialPhase || 'vibe'); - const active = COMMITMENT_TYPES.find(t => t.key === phase) || COMMITMENT_TYPES[0]; - return ( -
-
-

Commitments

-

Stake SP on a goal — hit it by the deadline to win it back multiplied; miss and lose a penalty. One active per phase.

-
- {COMMITMENT_TYPES.map(t => ( - - ))} -
-
- {active.ready - ? (active.key === 'vibe' ? : ) - :

{active.blurb}{!['standup', 'vibe'].includes(active.key) && <>
Coming soon — same stake-and-win mechanic, tuned to this phase.}

} -
- ); -} - -function VibeGoals({ student }) { - const email = student.email; - const [data, setData] = useState(null); - const [form, setForm] = useState({ goalPct: 20, stake: 100, multiplier: 4, deadline: '' }); - const [editing, setEditing] = useState(false); - const [err, setErr] = useState(null); - - const load = async () => { - const r = await fetch(`${API}/vibe/state?email=${encodeURIComponent(email)}`); - setData(await r.json()); - }; - useEffect(() => { - load(); - const d = new Date(); d.setDate(d.getDate() + 2); - setForm(f => ({ ...f, deadline: d.toISOString().slice(0, 10) })); - }, [email]); - - if (!data) return
Loading ViBe Goals…
; - if (!data.eligible) return
ViBe Goals isn’t available for your cohort yet.
; - - const cur = data.current, cfg = data.config; - const s = +form.stake, m = +form.multiplier, g = +form.goalPct; - const loss = cfg.penaltyFactor * s * m, win = s * m, need = s + loss; // stake debited + worst-case penalty - const daysOut = form.deadline - ? Math.round((new Date(form.deadline).setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / 86400000) : 0; - const availForBet = data.available + (editing && data.active ? data.active.reserved + data.active.stake : 0); - - let problem = null; - if (!cur) problem = 'All courses complete — nothing to commit to.'; - else if (g <= cur.floorPct) problem = `Goal must beat the weekly floor (${cur.floorPct}%).`; - else if (daysOut < 1 || daysOut > cfg.maxBetDays) problem = `Deadline must be 1–${cfg.maxBetDays} days out.`; - else if (g > cur.remaining) problem = `Goal exceeds your remaining ${cur.remaining}%.`; - else if (need > availForBet) problem = `You need ${need} SP (stake ${s} + up to ${loss} loss); you have ${availForBet}.`; - - const post = async (url, body, method = 'POST') => { - const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - const j = await r.json(); if (!r.ok) { setErr(j.error); return null; } setErr(null); return j; - }; - const place = async () => { const j = await post(`${API}/vibe/bet`, - { email, course: cur.key, goalPct: g, stake: s, multiplier: m, deadline: form.deadline }); if (j) setData(j); }; - const saveEdit = async () => { const j = await post(`${API}/vibe/bet/${data.active._id}`, - { email, goalPct: g, stake: s, multiplier: m }, 'PUT'); if (j) { setEditing(false); setData(j); } }; - const settle = async (result) => { const j = await post(`${API}/vibe/bet/${data.active._id}/settle`, - { email, result }); if (j) { setEditing(false); setData(j); } }; - - const showForm = cur && (!data.active || editing); - - return ( -
-
-

Your course path

-

Courses unlock in order — you work on and set commitments for your current course only. Prior completions are credited automatically.

-
- {data.ladder.map((l, i) => ( - - {i > 0 &&
} -
- {i + 1}{l.name} - {l.prior ? 'credited ✓' : l.cleared ? '100% ✓' : (cur && cur.key === l.key ? `${l.pct}% · in progress` : '🔒 locked')} -
-
- ))} -
-
- - {cur && ( -
-

Current course — {cur.name}

-
-
- This week (floor) - {data.weeklyFloor.doneHours} h - {cfg.floorHours} h required · {data.weeklyFloor.met - ? +{cfg.floorSp} SP earned - : not yet} -
-
- {cur.name} — completion - {cur.pct}% - {cur.remaining}% left · ≈ {(cur.pct / 100 * cur.hours).toFixed(1)} / {cur.hours} h* -
-
-
-
- )} - - {cur && ( -
-

{editing ? 'Edit your commitment' : 'Set a goal & commit extra SP'}

-

Your stake is debited now. Hit your goal by the deadline → win it back multiplied; miss → lose an extra penalty on top. One commitment per course, deadline up to {cfg.maxBetDays} days away.

- {!showForm && data.active && -
You have an active commitment on {cur.name}. Edit it below, or resolve it with the demo buttons.
} - {showForm && ( -
-
-
-
setForm({ ...form, goalPct: e.target.value })} />%
- Allowed {cur.floorPct}%–{cur.remaining}% (floor → remaining) · ≈ {(g / 100 * cur.hours).toFixed(1)} h -
-
- setForm({ ...form, deadline: e.target.value })} /> - {editing ? 'Fixed — can’t be changed after placing.' : `Up to ${cfg.maxBetDays} days away.`} -
-
- setForm({ ...form, stake: e.target.value })} /> - {cfg.stakeMin}–{cfg.stakeMax} SP. -
-
-
{cfg.multipliers.map(x => - )}
-
-
-
Staked now−{s}
-
If you HIT+{win}net +{win - s}
-
If you MISS−{loss}net −{s + loss}
-
Left after placing{availForBet - s - loss}
-
-
- {editing - ? <> - - : } - {problem || `✓ Covered — ${loss} SP reserved until it settles.`} -
- {err &&

{err}

} -
- )} -
- )} - -
-

Your active commitment

- {data.active ? ( -
-
-

{courseName(data.ladder, data.active.course)} — raise completion by {data.active.goalPct}%

-
staked {data.active.stake} (debited) @ {data.active.multiplier}× · by {new Date(data.active.deadline).toLocaleDateString()} · risk −{data.active.potentialLoss} more on miss
-
-
-
Hit +{data.active.potentialWin} / Miss −{data.active.potentialLoss}
-
- {!editing && } - - -
-
-
- ) :

No active commitment right now — set one above.

} -
- -
-

Past commitments

- {data.history.length ? ( - - {data.history.map(b => ( - - - ))} -
CourseGoalStakeResultNet SP
{courseName(data.ladder, b.course)}+{b.goalPct}%{b.stake} @ {b.multiplier}×{b.status === 'won' ? 'HIT' : 'MISS'}{netFor(b) >= 0 ? '+' : ''}{netFor(b)}
- ) :

No settled commitments yet.

} -
-
- ); -} - -// Standup commitment — weekly, attendance-only, keep-the-stake. Student picks a tier -// (81–90 → stake 20 / 91–100 → stake 50, fixed) and a confidence (2×/3×/4×). HIT pays -// +stake×conf on top of earned attendance; MISS charges −0.5×stake×conf off the balance. -function StandupGoals({ student }) { - const email = student.email; - const [data, setData] = useState(null); - const [tierKey, setTierKey] = useState('91-100'); - const [multiplier, setMultiplier] = useState(4); - const [err, setErr] = useState(null); - - const load = async () => { - const r = await fetch(`${API}/standup/state?email=${encodeURIComponent(email)}`); - setData(await r.json()); - }; - useEffect(() => { load(); }, [email]); - - if (!data) return
Loading standups…
; - if (!data.eligible) return
Standup commitments aren’t available for your cohort yet.
; - - const tier = data.tiers.find(t => t.key === tierKey) || data.tiers[0]; - const stake = tier.stake, win = stake * multiplier, loss = data.penaltyFactor * stake * multiplier; - const problem = data.active - ? 'You already have an active standup commitment this week.' - : loss > data.available ? `You need ${loss} SP free to cover a possible miss; you have ${data.available}.` : null; - - const post = async (url, body) => { - const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - const j = await r.json(); if (!r.ok) { setErr(j.error); return null; } setErr(null); return j; - }; - const place = async () => { const j = await post(`${API}/standup/commit`, { email, tierKey, multiplier }); if (j) setData(j); }; - const settle = async (result) => { const j = await post(`${API}/standup/commit/${data.active._id}/settle`, { email, result }); if (j) setData(j); }; - - return ( -
-
-

This week’s standups — {data.weekLabel}

-

Pledge to attend all {data.sessionsThisWeek} standups this week at a chosen attendance tier. Attendance only — polls stay as poll-points. Your stake isn’t deducted: hit your pledge for a bonus on top of the attendance points you earn, miss and a penalty applies.

-
-
Attended so far{data.attendedThisWeek}/{data.sessionsThisWeek}this week
-
Avg attendance{data.avgPctThisWeek != null ? data.avgPctThisWeek + '%' : '—'}so far
-
-
- - {!data.active && ( -
-

Set a standup commitment

-
-
-
{data.tiers.map(t => - )}
- Higher tier = higher bar and bigger reward. Beating your tier still counts as a hit. -
-
-
{data.multipliers.map(x => - )}
-
-
-
Stake (fixed by tier){stake}
-
If you HIT+{win}bonus, on top of attendance
-
If you MISS−{loss}penalty off your balance
-
-
- - {problem || `✓ Covered · settles ${new Date(data.deadline).toLocaleDateString()}`} -
- {err &&

{err}

} -
-
- )} - -
-

Your active commitment

- {data.active ? ( -
-
-

{data.active.label}

-
stake {data.active.stake} (kept) · by {new Date(data.active.deadline).toLocaleDateString()} · risk −{data.active.potentialLoss} on miss
-
-
-
Hit +{data.active.potentialWin} / Miss −{data.active.potentialLoss}
-
- - -
-
-
- ) :

No active standup commitment — set one above.

} -
- -
-

Past standup commitments

- {data.history.length ? ( - - {data.history.map(c => ( - - - ))} -
Week pledgeTierResultSP
{c.label}{c.tier}{c.status === 'won' ? 'HIT' : 'MISS'}{c.resultDelta >= 0 ? '+' : ''}{c.resultDelta}
- ) :

No settled standup commitments yet.

} -
-
- ); -} - function AdminView({ admin, auth, onBack }) { const [tab, setTab] = useState('leaderboard'); const [leaderLimit, setLeaderLimit] = useState(50); diff --git a/client/src/styles.css b/client/src/styles.css index cfc87f1..e135100 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -223,7 +223,7 @@ input { @media (max-width: 720px) { .level-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .pulse-grid { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } @@ -250,45 +250,6 @@ input { } .pulse-card p { color: var(--muted); margin-bottom: 6px; } .wide-pulse { grid-column: span 2; } -.pulse-clickable { cursor: pointer; text-align: left; border: none; font: inherit; width: 100%; } -.pulse-clickable:hover { box-shadow: 0 0 0 2px var(--primary) inset; } -.expand-hint { color: var(--primary); font-style: normal; font-size: 11px; font-weight: 700; margin-left: 6px; } -.traj-modal { width: min(880px, 100%); } -.traj-legend { display: flex; flex-wrap: wrap; gap: 16px; margin: 6px 0 4px; } -.traj-key { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: var(--text); } -.traj-key i { width: 16px; height: 3px; border-radius: 2px; display: inline-block; } -.traj-chart { width: 100%; overflow-x: auto; } -.traj-chart svg { width: 100%; height: auto; min-width: 480px; } -.traj-grid { stroke: var(--line); stroke-width: 1; } -.traj-axis { fill: var(--muted); font-size: 11px; } -.traj-axis-title { fill: var(--muted); font-size: 12px; font-weight: 700; } -.traj-foot { margin-top: 10px; font-size: 12px; } - -/* ---- My Journey: goal setup + pace bars --------------------------------- */ -.jr-goalset { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; padding: 10px 0; border-top: 1px solid var(--line); } -.jr-goalset:first-of-type { border-top: none; padding-top: 4px; } -.jr-goalset label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); } -.jr-goalset input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-missed { color: var(--red); font-size: 12px; font-weight: 800; } -.jr-progress i.done { background: var(--green); } -.jr-pill.green { background: #e9f7ee; color: var(--green); } -.jr-trajlink { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.jr-trajlink h2 { margin: 0; } -.jr-intro h2 { margin-bottom: 4px; } -.jr-card { display: flex; flex-direction: column; } -.jr-goal { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); } -.jr-goal-label { display: block; font-size: 12.5px; font-weight: 800; color: var(--muted); margin-bottom: 6px; } -.jr-goal-label.done { color: var(--green); } -.jr-goal-label.miss { color: var(--red); } -.jr-goal-meta { display: block; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 6px; } -.jr-goal-foot { display: block; font-size: 12px; color: var(--muted); margin-top: 6px; } -.jr-goal-row { display: flex; gap: 8px; align-items: center; } -.jr-goal-row input { flex: 1; min-height: 34px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-goal-row button { min-height: 34px; white-space: nowrap; } -.jr-cardfoot { margin-top: auto; padding-top: 12px; display: flex; justify-content: flex-end; } -.jr-stake { background: none; border: 1px solid var(--primary); color: var(--primary); border-radius: 999px; padding: 6px 12px; font-weight: 800; font-size: 12px; cursor: pointer; } -.jr-stake:hover { background: var(--primary); color: #fff; } -.jr-goal-hint { display: block; font-size: 11.5px; color: var(--muted); margin-top: 6px; } .compare-list { display: grid; gap: 8px; } .compare-list b { font-size: 14px; } .badge-row { display: flex; flex-wrap: wrap; gap: 8px; } @@ -321,10 +282,6 @@ input { .empty { color: var(--muted); } .bank { display: grid; gap: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } -.bank-controls { display: flex; gap: 10px; align-items: center; } -.bank-controls label { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); font-weight: 700; } -.bank-controls select { min-height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 6px; } -.bank-foot { margin-top: 10px; font-size: 12.5px; } .bank-header, .bank-row { display: grid; grid-template-columns: 180px 80px 80px 80px minmax(260px, 1fr); @@ -557,134 +514,3 @@ input { .survey-primary:disabled { opacity: 0.6; cursor: default; } .survey-ghost { background: #fff; color: #475569; border-color: #cbd5e1; } .survey-note { margin: 0 24px 16px; font-size: 0.85rem; color: #b91c1c; } - -/* --- ViBe Goals tab -------------------------------------------------------- */ -.vg .muted { color: var(--muted); font-size: 13px; } -.vg .hint { font-size: 12px; color: var(--muted); } -.vg-ladder { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; margin-top: 12px; } -.vg-step { flex: 1; min-width: 150px; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; background: #fafdff; position: relative; } -.vg-step .n { position: absolute; top: 10px; right: 12px; width: 20px; height: 20px; border-radius: 50%; background: #e2e8f0; color: var(--muted); font-size: 12px; font-weight: 900; display: grid; place-items: center; } -.vg-step b { display: block; font-size: 15px; margin-bottom: 2px; } -.vg-step em { font-style: normal; font-size: 12px; color: var(--muted); } -.vg-step.done { background: #eefaf3; border-color: #bbe7cf; } -.vg-step.done .n { background: var(--green); color: #fff; } -.vg-step.current { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(23,107,135,.18); } -.vg-step.current .n { background: var(--primary); color: #fff; } -.vg-step.locked { opacity: .7; } -.vg-arrow { display: grid; place-items: center; color: var(--muted); font-size: 20px; font-weight: 900; } -.vg-tiles { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } -.vg-tile { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fafdff; } -.vg-tile > span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; font-weight: 800; } -.vg-tile > strong { display: block; font-size: 22px; margin: 6px 0 2px; color: var(--primary); } -.vg-tile > em { display: block; color: var(--muted); font-style: normal; font-size: 12px; } -.vg-tile.done { background: #eefaf3; border-color: #bbe7cf; } -.vg-tile.done > strong { color: var(--green); } -.vg-pill { display: inline-block; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; } -.vg-pill.green { background: #e9f7ee; color: var(--green); } -.vg-pill.amber { background: #fef3e2; color: var(--amber); } -.vg-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin-top: 8px; } -.vg-progress i { display: block; height: 100%; background: var(--primary); } -.vg-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.vg-field { display: grid; gap: 6px; } -.vg-field label { font-size: 13px; font-weight: 800; color: #334155; } -.vg-field input { width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 10px 12px; background: #fff; color: var(--text); } -.vg-field input[type=range] { padding: 0; accent-color: var(--primary); } -.vg-row { display: flex; align-items: center; gap: 8px; } -.vg-row input { max-width: 120px; } -.vg-wide { grid-column: 1 / -1; } -.vg-mult { display: flex; gap: 8px; } -.vg-mult button { flex: 1; border: 1px solid var(--line); background: #fff; border-radius: 7px; padding: 10px 0; font-weight: 850; color: var(--text); } -.vg-mult button.active { background: var(--primary); border-color: var(--primary); color: #fff; } -.vg-readout { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; border-top: 1px solid var(--line); padding-top: 14px; } -.vg-readout .r { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fbfdff; text-align: center; } -.vg-readout .r span { display: block; font-size: 12px; color: var(--muted); font-weight: 800; } -.vg-readout .r strong { display: block; font-size: 20px; margin-top: 4px; } -.vg-readout .win strong { color: var(--green); } -.vg-readout .lose strong { color: var(--red); } -.vg-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } -.vg-warn { color: var(--red); font-weight: 800; font-size: 13px; } -.vg-ok { color: var(--green); font-weight: 800; font-size: 13px; } -.vg-lock { border: 1px dashed var(--primary); background: #f0f8fb; color: var(--primary-dark); border-radius: 8px; padding: 12px 14px; font-weight: 700; font-size: 14px; margin-bottom: 14px; } -.vg-bet { border: 1px solid var(--line); border-left: 4px solid var(--primary); border-radius: 8px; padding: 14px; background: #fff; display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; } -.vg-bet h4 { margin: 0 0 4px; font-size: 15px; } -.vg-bet .meta { color: var(--muted); font-size: 13px; } -.vg-bet .side { text-align: right; } -.vg-bet .side .win { color: var(--green); font-weight: 850; } -.vg-bet .side .lose { color: var(--red); font-weight: 850; } -.vg-betbtns { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; } -.vg-betbtns button { min-height: 34px; padding: 0 10px; } -.vg-hit { color: var(--green); font-weight: 850; } -.vg-miss { color: var(--red); font-weight: 850; } -@media (max-width: 820px) { .vg-form { grid-template-columns: 1fr; } .vg-readout { grid-template-columns: 1fr 1fr; } .vg-tiles { grid-template-columns: 1fr; } } -.vg-readout .net { display: block; font-size: 11px; color: var(--muted); font-weight: 700; margin-top: 2px; } - -/* ---- My Journey (phase-by-phase progress + SP) ---------------------------- */ -.jr { display: grid; gap: 16px; } -.jr-plan-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; margin-top: 6px; } -.jr-plan-row label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); } -.jr-plan-row input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; } -.jr-saved { color: var(--green); font-weight: 800; font-size: 13px; } - -.jr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -@media (max-width: 720px) { .jr-grid { grid-template-columns: 1fr; } } - -.jr-card { background: var(--panel, #fff); border: 1px solid var(--line); border-radius: 12px; padding: 16px; border-top: 4px solid var(--primary); box-shadow: var(--shadow, 0 1px 2px rgba(0,0,0,.04)); } -.jr-card.phase-standups { border-top-color: #3b82f6; } -.jr-card.phase-vibe { border-top-color: #8b5cf6; } -.jr-card.phase-spa { border-top-color: #f59e0b; } -.jr-card.phase-project { border-top-color: #10b981; } - -.jr-head { display: flex; align-items: center; gap: 8px; } -.jr-head h3 { margin: 0; font-size: 16px; flex: 1; } -.jr-n { width: 22px; height: 22px; border-radius: 50%; background: var(--text); color: #fff; font-size: 12px; font-weight: 800; display: grid; place-items: center; } -.jr-sp { font-weight: 850; color: var(--green); font-size: 15px; } -.jr-sp.neg { color: var(--red); } -.jr-soon { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 3px 8px; } -.jr-sub { color: var(--muted); font-size: 13px; margin: 6px 0 12px; } - -.jr-stats { display: flex; gap: 18px; flex-wrap: wrap; } -.jr-stats div { display: flex; flex-direction: column; } -.jr-stats strong { font-size: 22px; line-height: 1.1; } -.jr-stats span { font-size: 12px; color: var(--muted); } -.jr-big { display: flex; align-items: baseline; gap: 6px; } -.jr-big strong { font-size: 30px; } -.jr-big span { color: var(--muted); font-size: 13px; } - -.jr-dots { display: flex; gap: 8px; } -.jr-dot { flex: 1; text-align: center; border: 1px solid var(--line); border-radius: 8px; padding: 8px 4px; } -.jr-dot.done { background: #ede9fe; border-color: #8b5cf6; } -.jr-dot.current { background: #f5f3ff; border-color: #8b5cf6; box-shadow: inset 0 0 0 1px #8b5cf6; } -.jr-dot b { display: block; font-size: 15px; } -.jr-dot span { font-size: 10px; color: var(--muted); } - -.jr-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin: 8px 0; } -.jr-progress i { display: block; height: 100%; background: #f59e0b; } - -.jr-splits { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 12px; } -.jr-pill { display: inline-block; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; background: #eef2ff; color: var(--text); } -.jr-pill.amber { background: #fef3e2; color: var(--amber); } -.jr-pill.muted { background: #f1f5f9; color: var(--muted); font-weight: 600; } -.jr-link { background: none; border: none; color: var(--primary); font-weight: 800; font-size: 12px; cursor: pointer; padding: 0; margin-left: auto; } - -/* ---- Commitments hub (sub-tabs, one phase at a time) --------------------- */ -.cm { display: grid; gap: 12px; } -.cm-subtabs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.cm-subtab { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 8px 14px; font: inherit; font-weight: 700; font-size: 13px; color: var(--muted); cursor: pointer; } -.cm-subtab:hover { border-color: var(--primary); color: var(--text); } -.cm-subtab.active { background: var(--primary); border-color: var(--primary); color: #fff; } -.cm-subtab.active .cm-tag { background: rgba(255, 255, 255, 0.24); color: #fff; } -.cm-acc { border: 1px solid var(--line); border-radius: 12px; background: var(--panel, #fff); overflow: hidden; border-left: 4px solid var(--line); } -.cm-acc.open { box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,.06)); } -.cm-acc.phase-vibe.open { border-left-color: #8b5cf6; } -.cm-acc.phase-standup.open { border-left-color: #3b82f6; } -.cm-acc.phase-spa.open { border-left-color: #f59e0b; } -.cm-acc.phase-project.open { border-left-color: #10b981; } -.cm-accbtn { width: 100%; display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: none; border: none; cursor: pointer; text-align: left; font: inherit; } -.cm-accbtn b { font-size: 15px; } -.cm-caret { color: var(--muted); font-size: 12px; width: 12px; } -.cm-tag { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 2px 8px; } -.cm-blurb { color: var(--muted); font-size: 12.5px; margin-left: auto; text-align: right; max-width: 46%; } -@media (max-width: 620px) { .cm-blurb { display: none; } } -.cm-body { padding: 4px 14px 14px; border-top: 1px solid var(--line); } -.cm-body .vg { margin-top: 8px; } -.cm-soon { color: var(--muted); font-size: 14px; line-height: 1.6; padding: 10px 2px; } diff --git a/pipeline/sp-rubric-build-mirror.cjs b/pipeline/sp-rubric-build-mirror.cjs index 1577ea9..3685335 100644 --- a/pipeline/sp-rubric-build-mirror.cjs +++ b/pipeline/sp-rubric-build-mirror.cjs @@ -65,50 +65,12 @@ const APPLY = process.env.APPLY === '1'; // 09:05 IST = 03:35 UTC. wEnd = min(first-instance-end, 11:00 IST). Per-day end overrides (IST) take precedence. const WINDOW_END_OVERRIDE_IST = { '2026-05-22': '11:00' }; -// From EVENING_CUTOVER the daily standup moved from the morning (09:05-11:00 IST) -// to the evening. On/after this date, score presence in [EVENING_WSTART_IST, -// EVENING_WEND_IST] IST (5-min join grace, mirroring the old morning window) and -// pick the mandatory meeting that overlaps THAT window (an all-day/leftover -// morning room must not steal the slot). Dates before the cutover are unchanged. -const EVENING_CUTOVER = '2026-07-16'; -const EVENING_WSTART_IST = '20:05'; -const EVENING_WEND_IST = '21:00'; const GRACE_DATE = '2026-06-06'; // exceptional: 1 min join = full att + full poll -// Polls also moved off Zoom to the Spandan evening classroom at the evening -// cutover. On/after this date the poll (B) score comes from `spandan_polls` -// (correctness, percentiled to the day's top scorer); strictly before it, from -// the frozen `zoom_polls` mirror (participation) exactly as history has it — so -// old poll SP is never disturbed. Same date as the evening attendance cutover. -const SPANDAN_CUTOFF = process.env.SPANDAN_CUTOFF || EVENING_CUTOVER; const STAFF = new Set([ 'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com', 'sudarshansudarshan@gmail.com', 'sudarshan@iitrpr.ac.in', 'rajankrsna@gmail.com', ]); -// ── SPA → SP (peer-teaching endorsement points; Pattern A: rubric-recomputed) ── -// SP for the SPA activity, scored here alongside attendance/poll so it is -// regenerated every rebuild (wipe-safe by construction — no preserved category). -// Only VALIDATED endorsements count (status approved|audit_passed); the raw -// act_spa_transactions.deltaSPA ledger is a runaway compounding value and is -// never used. Two capped tracks + a one-time integrity penalty on current SP. -// Source: act_spa_endorsements + act_spa_transactions (mirrored 6-hourly). -const SPA_LEARN_UNIT = 5, SPA_LEARN_CAP = 50; // +5 SP / validated question learned, cap 50 → max 250 -const SPA_TEACH_UNIT = 8, SPA_TEACH_CAP = 30; // +8 SP / validated peer taught, cap 30 → max 240 -const SPA_FRAUD_RATE = 0.5, SPA_AUDIT_RATE = 0.2; -const SPA_GOOD = ['approved', 'audit_passed']; - -// ── Query answering → SP (Pattern A: rubric-recomputed) ────────────────────── -// +5 SP per DISTINCT peer query a student answered (from -// act_query_reviews.peer.submittedAnswerHistory), self-answers excluded, no cap. -// Answering only — asking a question earns nothing. -const QUERY_UNIT = 5; - -// ── PRESERVED categories — NOT recomputable from Zoom source, so they must survive -// the delete-and-rebuild (else the wipe erases them every run). 'manual' = ViBe/ -// standup commitment SP (stake debits + wins) AND admin manual awards; 'peer_faq' = -// peer-review FAQ awards. We fold their deltas back into each student's balance. -const PRESERVED_CATS = ['manual', 'peer_faq']; - const isMandatory = (t) => /stand|orientation/i.test(t) && !/breakout|weekend|nptel|special|support|non[- ]?mandatory/i.test(t); const tier = (pct) => { pct = Math.min(100, pct); return pct >= 90 ? 10 : pct >= 75 ? 5 : pct >= 50 ? 3 : 0; }; const dstr = (d) => { if (!d) return null; const x = new Date(d); return isNaN(x) ? null : x.toISOString().slice(0, 10); }; @@ -154,53 +116,16 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+) const byDate = {}; for (const m of meetings) (byDate[m.date] = byDate[m.date] || []).push(m); const sessions = []; for (const date of Object.keys(byDate).sort()) { - const mandatory = byDate[date].filter((m) => isMandatory(m.topic) && (m.participantsCount || 0) >= 10); - if (!mandatory.length) continue; - let first, wStart, wEnd; - if (date >= EVENING_CUTOVER) { - // evening standup: fixed [20:05, 21:00] IST window; pick the mandatory meeting - // that overlaps it most so a leftover all-day/morning room can't steal the slot. - wStart = utcFromISTDate(date, EVENING_WSTART_IST); - const wCap = utcFromISTDate(date, EVENING_WEND_IST); - const scored = mandatory.map((m) => { - const ms = new Date(m.startTime).getTime(), me = new Date(m.endTime).getTime(); - return { m, ov: Math.max(0, Math.min(me, wCap) - Math.max(ms, wStart)) }; - }).sort((a, b) => b.ov - a.ov)[0]; - if (!scored || scored.ov <= 0) continue; // no mandatory meeting overlaps the evening window - first = scored.m; - wEnd = Math.min(new Date(first.endTime).getTime(), wCap); - } else { - first = mandatory.sort((a, b) => new Date(a.startTime) - new Date(b.startTime))[0]; - wStart = utcFromISTDate(date, '09:05'); - wEnd = WINDOW_END_OVERRIDE_IST[date] ? utcFromISTDate(date, WINDOW_END_OVERRIDE_IST[date]) : Math.min(new Date(first.endTime).getTime(), utcFromISTDate(date, '11:00')); - } + const first = byDate[date].filter((m) => isMandatory(m.topic) && (m.participantsCount || 0) >= 10).sort((a, b) => new Date(a.startTime) - new Date(b.startTime))[0]; + if (!first) continue; + const wStart = utcFromISTDate(date, '09:05'); + const wEnd = WINDOW_END_OVERRIDE_IST[date] ? utcFromISTDate(date, WINDOW_END_OVERRIDE_IST[date]) : Math.min(new Date(first.endTime).getTime(), utcFromISTDate(date, '11:00')); sessions.push({ date, uuid: first._id, topic: first.topic, wStart, wEnd, label: dayLabel(first.topic) }); } // 3. per-student per-session attendance (A) + poll (B), all from the mirror const students = new Map(); // email -> { name, firstAtt, rows:[{date,order,cat,delta,reason}] } const touch = (email, name) => { const e = email.toLowerCase().trim(); if (!students.has(e)) students.set(e, { name: name || e, firstAtt: null, rows: [] }); const o = students.get(e); if (name && !name.includes('@')) o.name = name; return o; }; - - // Spandan evening-poll mirror (Day-N sessions >= SPANDAN_CUTOFF), keyed by date. - // Poll (B) here is correctness-based, percentiled to the day's TOP scorer: - // pct = pointsEarned / dayTopPoints * 100, then the same 10/5/3/0 band ladder. - const spandanByDate = new Map(); - for (const sp of await sak.collection('spandan_polls').find({ date: { $gte: SPANDAN_CUTOFF } }).toArray()) { - const prev = spandanByDate.get(sp.date); - if (!prev || (sp.studentCount || 0) > (prev.studentCount || 0)) spandanByDate.set(sp.date, sp); // one Day-N/day; keep the fullest - } - const scoreSpandanPoll = (sp, label) => { - const top = sp.topPoints || (sp.students || []).reduce((mx, x) => Math.max(mx, x.pointsEarned || 0), 0); - for (const x of sp.students || []) { - const e = String(x.email || '').toLowerCase().trim(); if (!e) continue; - const pct = top ? Math.round((x.pointsEarned || 0) / top * 1000) / 10 : 0; - const d = tier(pct); - // Short bank message: conveys correctness-based + relative-to-day-top in one line. - touch(e).rows.push({ date: sp.date, order: 2, cat: 'poll', delta: d, - reason: `${label} (${ddmon(sp.date)}): ${pct}% of day's top poll score -> ${d > 0 ? '+' : ''}${d} SP (correctness-based).` }); - } - }; - for (const s of sessions) { const winMin = Math.round((s.wEnd - s.wStart) / 60000); // attendance via zoom_attendance mirror (firstJoin/lastLeave), clipped to window @@ -221,95 +146,21 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+) touch(e, v.name).rows.push({ date: s.date, order: 1, cat: 'attendance', delta: d, reason: `${s.label} (${ddmon(s.date)}): present ${mins} of ${winMin} min (${pct}%) within official ${istHHMM(s.wStart)}-${istHHMM(s.wEnd)} IST window -> ${d > 0 ? '+' : ''}${d} SP.` }); const o = students.get(e); if (!o.firstAtt || s.date < o.firstAtt) o.firstAtt = s.date; } - // poll (B): Spandan evening performance on/after the cutoff; Zoom participation before it. - if (s.date >= SPANDAN_CUTOFF) { - const sp = spandanByDate.get(s.date); - if (sp) { scoreSpandanPoll(sp, s.label); spandanByDate.delete(s.date); } // consumed: covered by an evening session - } else { - // poll participation via zoom_polls for the same instance - const polls = await sak.collection('zoom_polls').find({ meetingUuid: s.uuid }).toArray(); - const totalQ = new Set(polls.map((p) => p.question)).size; - if (totalQ > 0) { - const ans = new Map(); for (const p of polls) { const e = String(p.email || '').toLowerCase().trim(); if (!e) continue; if (!ans.has(e)) ans.set(e, new Set()); if (p.answer && String(p.answer).trim()) ans.get(e).add(p.question); } - const present = new Set([...segByEmail.keys(), ...ans.keys()]); - for (const e of present) { - const a = (s.date === GRACE_DATE && segByEmail.has(e)) ? totalQ : (ans.get(e) || new Set()).size; const pct = Math.round(a / totalQ * 1000) / 10; const d = tier(pct); - touch(e).rows.push({ date: s.date, order: 2, cat: 'poll', delta: d, reason: `${s.label} (${ddmon(s.date)}): answered ${a} of ${totalQ} poll questions (${pct}%) -> ${d > 0 ? '+' : ''}${d} SP.` }); - } + // poll participation via zoom_polls for the same instance + const polls = await sak.collection('zoom_polls').find({ meetingUuid: s.uuid }).toArray(); + const totalQ = new Set(polls.map((p) => p.question)).size; + if (totalQ > 0) { + const ans = new Map(); for (const p of polls) { const e = String(p.email || '').toLowerCase().trim(); if (!e) continue; if (!ans.has(e)) ans.set(e, new Set()); if (p.answer && String(p.answer).trim()) ans.get(e).add(p.question); } + const present = new Set([...segByEmail.keys(), ...ans.keys()]); + for (const e of present) { + const a = (s.date === GRACE_DATE && segByEmail.has(e)) ? totalQ : (ans.get(e) || new Set()).size; const pct = Math.round(a / totalQ * 1000) / 10; const d = tier(pct); + touch(e).rows.push({ date: s.date, order: 2, cat: 'poll', delta: d, reason: `${s.label} (${ddmon(s.date)}): answered ${a} of ${totalQ} poll questions (${pct}%) -> ${d > 0 ? '+' : ''}${d} SP.` }); } } } - // Spandan poll days with no mandatory evening session still earn poll SP (label from the Day number). - for (const [, sp] of spandanByDate) scoreSpandanPoll(sp, 'Day ' + sp.dayNumber); - - // 3b. SPA → per-canon validated learn/teach events (dated) + integrity flags. - // emailToCanon is fully built by now, so we can fold aliases correctly. - const spaByCanon = new Map(); // canon -> { learn:[YYYY-MM-DD...], teach:[...] } - const spaFlag = new Map(); // canon -> { auditFail?, fraud? } - const canonOf = (e) => { const k = String(e || '').toLowerCase().trim(); return emailToCanon.get(k) || k; }; - const touchSpa = (c) => { let o = spaByCanon.get(c); if (!o) { o = { learn: [], teach: [] }; spaByCanon.set(c, o); } return o; }; - for (const en of await sak.collection('act_spa_endorsements').find( - { status: { $in: SPA_GOOD } }, - { projection: { learnerEmail: 1, teacherEmail: 1, approvedAt: 1, updatedAt: 1, createdAt: 1 } }).toArray()) { - const d = dstr(en.approvedAt) || dstr(en.updatedAt) || dstr(en.createdAt); if (!d) continue; - if (en.learnerEmail) touchSpa(canonOf(en.learnerEmail)).learn.push(d); - if (en.teacherEmail) touchSpa(canonOf(en.teacherEmail)).teach.push(d); - } - // Genuine fraud = net teacher_fraud_penalty + fraud_penalty_reversal < 0, with - // operator "Testing" rows excluded (they are demote-feature tests, all reversed). - for (const f of await sak.collection('act_spa_transactions').aggregate([ - { $match: { transactionType: { $in: ['teacher_fraud_penalty', 'fraud_penalty_reversal'] }, reason: { $not: /testing/i } } }, - { $group: { _id: { $toLower: '$email' }, net: { $sum: '$deltaSPA' } } }]).toArray()) { - if (f.net < 0) { const c = canonOf(f._id); spaFlag.set(c, { ...(spaFlag.get(c) || {}), fraud: true }); } - } - for (const a of await sak.collection('act_spa_transactions').aggregate([ - { $match: { transactionType: { $in: ['audit_failure_learner_penalty', 'audit_failure_teacher_penalty'] } } }, - { $group: { _id: { $toLower: '$email' } } }]).toArray()) { - const c = canonOf(a._id); spaFlag.set(c, { ...(spaFlag.get(c) || {}), auditFail: true }); - } - - // 3d. Query answering → per-canon distinct queries answered (dated). Answerer = - // peer.submittedAnswerHistory (userIds); map userId→email via an act_* crosswalk, - // canonicalize, and drop self-answers (answerer == asker). Non-students fall out - // naturally (only canons in `candidates` get rows below). - const uidToEmail = new Map(); - for (const c of ['act_query_reviews', 'act_pull_requests', 'act_cs_faq', 'act_spa_rosters']) { - for (const r of await sak.collection(c).find({ userId: { $ne: null }, email: { $ne: null } }, { projection: { userId: 1, email: 1 } }).toArray()) - uidToEmail.set(String(r.userId), String(r.email).toLowerCase().trim()); - } - const queryByCanon = new Map(); // canon -> [YYYY-MM-DD ...] (one per distinct query answered) - for (const q of await sak.collection('act_query_reviews').find( - { 'peer.submittedAnswerHistory.0': { $exists: true } }, - { projection: { userId: 1, createdAt: 1, updatedAt: 1, 'peer.submittedAnswerHistory': 1, 'peer.answer.submittedAt': 1 } }).toArray()) { - const askerId = String(q.userId); - const date = dstr(q.peer?.answer?.submittedAt) || dstr(q.createdAt) || dstr(q.updatedAt); if (!date) continue; - const seen = new Set(); - for (let uid of (q.peer.submittedAnswerHistory || [])) { - uid = String(uid); if (uid === askerId || seen.has(uid)) continue; seen.add(uid); - const e = uidToEmail.get(uid); if (!e) continue; - const c = canonOf(e); - let arr = queryByCanon.get(c); if (!arr) { arr = []; queryByCanon.set(c, arr); } - arr.push(date); - } - } - - // 3e. PRESERVED rows (manual/peer_faq) — read BEFORE the wipe and fold into each - // student's ledger so commitment/admin SP survives the rebuild. Re-created with - // the same delta/date/reason (metadata like original createdAt is not retained). - const preservedByCanon = new Map(); // canon -> [{ date, order, cat, delta, reason }] - for (const t of await sak.collection('sptransactions').find({ category: { $in: PRESERVED_CATS } }).toArray()) { - const e = String(t.email || '').toLowerCase().trim(); if (!e) continue; - const c = canonOf(e); - const date = dstr(t.dateTime) || dstr(t.createdAt) || TODAY; - const delta = Number(typeof t.appliedDelta === 'number' ? t.appliedDelta : t.deltaValue) || 0; - let arr = preservedByCanon.get(c); if (!arr) { arr = []; preservedByCanon.set(c, arr); } - arr.push({ date, order: 5, cat: t.category, delta, reason: t.reason || '' }); - } - // 4. assemble ledger, ROSTER-DRIVEN union: base 100 to every started intern. const ledger = []; const setAside = []; const finalBal = new Map(); const zeroOut = []; const nameByCanon = new Map(); - const spaSummary = []; // per-canon SPA breakdown for the web-app SPA tab (spaprogresses) const candidates = new Map(); // identity email -> { start, emails:[..], name } // (a) confirmed candidates interns (incl. those who never attended). Start comes // from the person's OWN record (internStart), not an alias-polluted lookup. @@ -330,42 +181,9 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+) const best = new Map(); for (const e of info.emails) { const o = students.get(e); if (!o) continue; if (info.name === cand && o.name && !o.name.includes('@')) info.name = o.name; for (const r of o.rows) { if (r.date < info.start) continue; const k = r.date + '|' + r.cat; const cur = best.get(k); if (!cur || r.delta > cur.delta) best.set(k, r); } } const rows = [{ date: info.start, order: 0, cat: 'initial', delta: 100, reason: `Base Spurti Points (100) credited on internship start date ${info.start}.` }, ...best.values()]; - // SPA rows (Pattern A): one consolidated 'spa' row per day, cumulative caps - // across days. These bypass the (date|cat) best-dedup by being pushed directly. - const spa = spaByCanon.get(cand); const flags = spaFlag.get(cand) || {}; - let spaLearnUsed = 0, spaTeachUsed = 0; - if (spa) { - const byDay = new Map(); // date -> { learn, teach } - for (const d of spa.learn.slice().sort()) { if (spaLearnUsed >= SPA_LEARN_CAP) break; spaLearnUsed++; const o = byDay.get(d) || { learn: 0, teach: 0 }; o.learn++; byDay.set(d, o); } - for (const d of spa.teach.slice().sort()) { if (spaTeachUsed >= SPA_TEACH_CAP) break; spaTeachUsed++; const o = byDay.get(d) || { learn: 0, teach: 0 }; o.teach++; byDay.set(d, o); } - for (const [d, o] of byDay) { - const delta = o.learn * SPA_LEARN_UNIT + o.teach * SPA_TEACH_UNIT; if (!delta) continue; - const parts = []; if (o.learn) parts.push(`${o.learn} learned`); if (o.teach) parts.push(`${o.teach} taught`); - rows.push({ date: d, order: 3, cat: 'spa', delta, reason: `SPA (${ddmon(d)}): ${parts.join(' + ')} (validated) -> +${delta} SP.` }); - } - } - // Integrity penalty: one-time -% of current total SP (fraud takes precedence). - const penRate = flags.fraud ? SPA_FRAUD_RATE : (flags.auditFail ? SPA_AUDIT_RATE : 0); - let spaPenalty = 0; - if (penRate > 0) { - spaPenalty = Math.round(rows.reduce((a, r) => a + r.delta, 0) * penRate); - if (spaPenalty > 0) rows.push({ date: TODAY, order: 9, cat: 'spa', delta: -spaPenalty, - reason: `SPA (${ddmon(TODAY)}): ${flags.fraud ? 'fraud' : 'audit-failure'} penalty -${Math.round(penRate * 100)}% of current SP -> -${spaPenalty} SP.` }); - } - // Query-answer rows: +5 per distinct peer query answered, one 'query' row per day. - const qDates = queryByCanon.get(cand); - if (qDates && qDates.length) { - const qByDay = new Map(); - for (const d of qDates) qByDay.set(d, (qByDay.get(d) || 0) + 1); - for (const [d, n] of qByDay) rows.push({ date: d, order: 4, cat: 'query', delta: n * QUERY_UNIT, - reason: `Query answering (${ddmon(d)}): ${n} peer quer${n === 1 ? 'y' : 'ies'} answered -> +${n * QUERY_UNIT} SP.` }); - } - // Preserved rows (manual commitment/admin SP + peer_faq) — fold in so they survive the wipe. - for (const p of (preservedByCanon.get(cand) || [])) rows.push(p); rows.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order); let bal = 0; for (const r of rows) { bal += r.delta; ledger.push({ email: cand, name: info.name, ...r, balanceAfter: bal }); } finalBal.set(cand, bal); nameByCanon.set(cand, info.name); - if (spa || penRate > 0) spaSummary.push({ email: cand, learnValidated: spa ? spa.learn.length : 0, teachValidated: spa ? spa.teach.length : 0, learnCredited: spaLearnUsed, teachCredited: spaTeachUsed, auditFail: !!flags.auditFail, fraud: !!flags.fraud, penaltyApplied: spaPenalty }); } for (const [e, o] of students) { if (STAFF.has(e) || matched.has(e) || emailToCanon.has(e)) continue; setAside.push({ email: e, name: o.name, rows: o.rows.length }); } @@ -409,12 +227,6 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+) const docs = ledger.map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; }); let ins = 0; for (let i = 0; i < docs.length; i += 2000) { await Tx.insertMany(docs.slice(i, i + 2000), { ordered: false }); ins += Math.min(2000, docs.length - i); } console.log(`APPLIED -> students upserted ${sBulk.length}, old txns deleted ${del}, new txns inserted ${ins}`); - // SPA summary for the web-app SPA tab (display only; SP itself is in the ledger above). - const Spa = sak.collection('spaprogresses'); - const spaOps = spaSummary.map((s) => ({ updateOne: { filter: { email: s.email }, - update: { $set: { ...s, activity: 'Activity 1: Linear Algebra', updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } }, upsert: true } })); - for (let i = 0; i < spaOps.length; i += 1000) await Spa.bulkWrite(spaOps.slice(i, i + 1000), { ordered: false }); - console.log(`SPA -> spaprogresses upserted ${spaOps.length}`); // RECONCILE: the new ledger is the COMPLETE source of truth — all current SP is // rubric-generated (initial/attendance/poll only; no admin/discretionary txns // exist). Any student NOT in the new ledger must be cleared so the leaderboard diff --git a/pipeline/sp-rubric-build.js b/pipeline/sp-rubric-build.js index 9a9c2d8..43e70f5 100644 --- a/pipeline/sp-rubric-build.js +++ b/pipeline/sp-rubric-build.js @@ -68,11 +68,6 @@ const STAFF = new Set([ 'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com', 'sudarshansudarshan@gmail.com', 'sudarshan@iitrpr.ac.in', 'rajankrsna@gmail.com', ]); -// Categories THIS script owns (fully recomputed every run) vs. discretionary -// award rows written by the app (admin manual + peer-review FAQ) that must be -// PRESERVED across rebuilds — never wiped (the 3d self-wipe bug). -const OWNED_CATS = ['initial', 'attendance', 'poll']; -const PRESERVED_CATS = ['manual', 'peer_faq']; const isMandatory = (t) => /stand|orientation/i.test(t) && !/breakout|weekend|nptel|special|support|non[- ]?mandatory/i.test(t); const tier = (pct) => { pct = Math.min(100, pct); return pct >= 90 ? 10 : pct >= 75 ? 5 : pct >= 50 ? 3 : 0; }; @@ -126,23 +121,6 @@ async function participants(uuid) { if (d) { internCanon.set(canon, emails); for (const e of emails) emailToCanon.set(e, canon); nameBy.set(canon, u.name || canon); } } - // 1b. Preserve discretionary awards (admin manual + peer-review FAQ). This - // script fully recomputes initial/attendance/poll each run; award rows the - // app writes into sakshi_spurti.sptransactions must survive the rebuild, - // else the nightly delete-and-reinsert erases them (the 3d self-wipe). We - // fold their deltas into each student's running balance and re-point - // balanceAfter below, but never delete/recreate them — so awardedBy, - // createdAt and other metadata are kept intact. - const preservedByCanon = new Map(); // canon email -> [{ _id, date, order, cat, delta, reason }] - for (const t of await sak.collection('sptransactions').find({ category: { $in: PRESERVED_CATS } }).toArray()) { - const e = String(t.email || '').toLowerCase().trim(); if (!e) continue; - const canon = emailToCanon.get(e) || e; - const date = dstr(t.dateTime) || dstr(t.createdAt) || TODAY; - const delta = Number(typeof t.appliedDelta === 'number' ? t.appliedDelta : t.deltaValue) || 0; - if (!preservedByCanon.has(canon)) preservedByCanon.set(canon, []); - preservedByCanon.get(canon).push({ _id: t._id, date, order: 3, cat: t.category, delta, reason: t.reason || '' }); - } - // 2. mandatory first-instance per day + official window const meetings = await zoom.collection('meetings').find({ date: { $gte: START_DATE } }).sort({ date: 1, startTime: 1 }).toArray(); const byDate = {}; for (const m of meetings) (byDate[m.date] = byDate[m.date] || []).push(m); @@ -207,7 +185,6 @@ async function participants(uuid) { const best = new Map(); for (const e of info.emails) { const o = students.get(e); if (!o) continue; if (info.name === cand && o.name && !o.name.includes('@')) info.name = o.name; for (const r of o.rows) { if (r.date < info.start) continue; const k = r.date + '|' + r.cat; const cur = best.get(k); if (!cur || r.delta > cur.delta) best.set(k, r); } } const rows = [{ date: info.start, order: 0, cat: 'initial', delta: 100, reason: `Base Spurti Points (100) credited on internship start date ${info.start}.` }, ...best.values()]; - for (const p of (preservedByCanon.get(cand) || [])) rows.push(p); // fold discretionary awards into the running balance rows.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order); let bal = 0; for (const r of rows) { bal += r.delta; ledger.push({ email: cand, name: info.name, ...r, balanceAfter: bal }); } finalBal.set(cand, bal); nameByCanon.set(cand, info.name); @@ -248,17 +225,14 @@ async function participants(uuid) { } for (let i = 0; i < sBulk.length; i += 1000) await Students.bulkWrite(sBulk.slice(i, i + 1000), { ordered: false }); const idMap = new Map(); for (const s of await Students.find({ email: { $in: emails } }, { projection: { email: 1 } }).toArray()) idMap.set(s.email, s._id); - // replace ONLY the categories this script owns; discretionary award rows are kept in place. - let del = 0; for (let i = 0; i < emails.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: emails.slice(i, i + 500) }, category: { $in: OWNED_CATS } }); del += r.deletedCount; } - const docs = ledger.filter((r) => !r._id).map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; }); + // replace transactions + let del = 0; for (let i = 0; i < emails.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: emails.slice(i, i + 500) } }); del += r.deletedCount; } + const docs = ledger.map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; }); let ins = 0; for (let i = 0; i < docs.length; i += 2000) { await Tx.insertMany(docs.slice(i, i + 2000), { ordered: false }); ins += Math.min(2000, docs.length - i); } - // re-point balanceAfter on preserved award rows (kept in place, metadata intact) - const presOps = ledger.filter((r) => r._id).map((r) => ({ updateOne: { filter: { _id: r._id }, update: { $set: { balanceAfter: r.balanceAfter } } } })); - let upd = 0; for (let i = 0; i < presOps.length; i += 1000) { const r = await Tx.bulkWrite(presOps.slice(i, i + 1000), { ordered: false }); upd += (r.modifiedCount || 0); } - console.log(`APPLIED -> students upserted ${sBulk.length}, owned txns deleted ${del}, new txns inserted ${ins}, awards preserved/repointed ${presOps.length} (modified ${upd})`); + console.log(`APPLIED -> students upserted ${sBulk.length}, old txns deleted ${del}, new txns inserted ${ins}`); // zero students who attended but are future-start or yet-to-onboard (meter not started) if (zeroOut.length) { - let zdel = 0; for (let i = 0; i < zeroOut.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: zeroOut.slice(i, i + 500) }, category: { $in: OWNED_CATS } }); zdel += r.deletedCount; } + let zdel = 0; for (let i = 0; i < zeroOut.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: zeroOut.slice(i, i + 500) } }); zdel += r.deletedCount; } for (let i = 0; i < zeroOut.length; i += 1000) await Students.bulkWrite(zeroOut.slice(i, i + 1000).map((email) => ({ updateOne: { filter: { email }, update: { $set: { totalSp: 0 } } } })), { ordered: false }); console.log(`ZEROED -> ${zeroOut.length} future-start/yet-to-onboard students (txns deleted ${zdel}, totalSp=0)`); } diff --git a/pipeline/spandan-poll-fetch.cjs b/pipeline/spandan-poll-fetch.cjs deleted file mode 100644 index 164a600..0000000 --- a/pipeline/spandan-poll-fetch.cjs +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; -/** - * spandan-poll-fetch.cjs - * - * Pulls ended poll sessions from the Spandan Research Session Export API and - * mirrors the qualifying ones into `spandan_polls` (one doc per session, keyed - * by roomId). This REPLACES the retired Zoom poll source for SP dates on/after - * the cutoff. It is purely additive/non-destructive: it never touches - * sptransactions, students, or any existing collection. - * - * Qualifying session = name matches /^Day N/ (the numbered classroom evening - * sessions) AND date >= CUTOFF. Non-Day sessions (FDP events, "19th July - * Evening Session" Sunday makeup) and pre-cutoff days are skipped. - * - * Incremental: stores the API's nextCursor in `spandan_sync` and passes it as - * ?since= on the next run, so a scheduled job never misses or double-counts. - * - * Env (from .env): MONGO_URI, SPANDAN_RESEARCH_KEY - * Flags: - * FULL=1 ignore the stored cursor and re-pull from the beginning (backfill) - * DRY=1 print what would be written; touch nothing - * - * Scoring itself lives in the rubric (sp-rubric-build-mirror.cjs), not here: - * per day, top scorer = 100%, others = pointsEarned / dayTop * 100, banded - * 10/5/3/0. This script just stores the raw session results + a convenience - * `topPoints` for that computation. - */ -const { MongoClient } = require('mongodb'); -require('dotenv').config(); - -const BASE = 'https://spandan.fun/spandan/api/research/sessions'; -const CUTOFF = '2026-07-16'; // first full-cohort evening session (Day 53) -const DAY_RE = /^Day\s+(\d+)\b/i; // only numbered "Day N ..." sessions count -const PAGE = 1000; - -const { MONGO_URI, SPANDAN_RESEARCH_KEY } = process.env; -const FULL = process.env.FULL === '1'; -const DRY = process.env.DRY === '1'; - -const lc = (s) => String(s || '').toLowerCase().trim(); - -async function fetchPage(since) { - const url = new URL(BASE); - url.searchParams.set('preset', 'evening'); - url.searchParams.set('limit', String(PAGE)); - if (since) url.searchParams.set('since', since); - const r = await fetch(url, { headers: { 'X-Research-Key': SPANDAN_RESEARCH_KEY } }); - if (!r.ok) throw new Error(`Spandan API ${r.status}: ${await r.text().catch(() => '')}`); - return r.json(); -} - -(async () => { - if (!MONGO_URI) { console.error('missing MONGO_URI'); process.exit(1); } - if (!SPANDAN_RESEARCH_KEY) { console.error('missing SPANDAN_RESEARCH_KEY'); process.exit(1); } - - const client = await MongoClient.connect(MONGO_URI); - const db = client.db(); // db name comes from the URI - const sync = db.collection('spandan_sync'); - const polls = db.collection('spandan_polls'); - - let since = null; - if (!FULL) { - const cur = await sync.findOne({ _id: 'cursor' }); - since = cur ? cur.value : null; - } - console.log(`spandan-poll-fetch: ${FULL ? 'FULL backfill' : since ? `since ${since}` : 'first run (all)'}${DRY ? ' [DRY]' : ''}`); - - let kept = 0, seen = 0, lastCursor = since; - while (true) { - const data = await fetchPage(since); - seen += data.count; - for (const s of data.sessions) { - const m = DAY_RE.exec(s.name || ''); - if (!m) continue; // not a numbered Day session - if (s.date < CUTOFF) continue; // pre-switchover - const students = (s.students || []).map((x) => ({ - email: lc(x.studentEmail), - pointsEarned: x.pointsEarned || 0, - questionsAnswered: x.questionsAnswered || 0, - })).filter((x) => x.email); - const topPoints = students.reduce((mx, x) => Math.max(mx, x.pointsEarned), 0); - const doc = { - roomId: s.roomId, - name: s.name, - dayNumber: Number(m[1]), - date: s.date, - endedAt: new Date(s.endedAt), - totalQuestions: s.totalQuestions || 0, - maxPoints: s.maxPoints || 0, - topPoints, - studentCount: students.length, - students, - updatedAt: new Date(), - }; - if (DRY) { - console.log(` KEEP ${doc.date} Day ${doc.dayNumber} | Q${doc.totalQuestions} max${doc.maxPoints} top${topPoints} | ${doc.studentCount} students`); - } else { - await polls.updateOne({ roomId: doc.roomId }, { $set: doc, $setOnInsert: { createdAt: new Date() } }, { upsert: true }); - } - kept++; - } - lastCursor = data.nextCursor || lastCursor; - if (!DRY && lastCursor) await sync.updateOne({ _id: 'cursor' }, { $set: { value: lastCursor, updatedAt: new Date() } }, { upsert: true }); - if (data.count < PAGE) break; // last page - since = data.nextCursor; - } - - console.log(`Done. scanned ${seen} evening session(s), kept ${kept} Day-N session(s) >= ${CUTOFF}. cursor=${lastCursor}${DRY ? ' (not saved)' : ''}`); - await client.close(); -})().catch((e) => { console.error('FATAL', e); process.exit(1); }); diff --git a/pipeline/sync-poll-records.js b/pipeline/sync-poll-records.js index 54f7daa..46e07dc 100644 --- a/pipeline/sync-poll-records.js +++ b/pipeline/sync-poll-records.js @@ -21,18 +21,6 @@ const POLL_RE = /answered (\d+) of (\d+) poll questions/; const students = await db.collection('students').find({}, { projection: { _id: 1, email: 1 } }).toArray(); const studentById = new Map(students.map(s => [s.email.toLowerCase().trim(), s._id])); - // Spandan-era poll counts (>= cutoff): the reason is short and correctness-based, - // so it doesn't carry "answered X of Y". Take participation straight from the - // spandan_polls mirror, joined to each poll txn by (email, date). - const CUTOFF = process.env.SPANDAN_CUTOFF || '2026-07-16'; - const spByEmailDate = new Map(); - for (const sp of await db.collection('spandan_polls').find({ date: { $gte: CUTOFF } }).toArray()) { - for (const x of sp.students || []) { - const e = String(x.email || '').toLowerCase().trim(); if (!e) continue; - spByEmailDate.set(e + '|' + sp.date, { attempted: x.questionsAnswered || 0, total: sp.totalQuestions || 0 }); - } - } - const txns = await db.collection('sptransactions') .find({ category: 'poll' }) .toArray(); @@ -44,16 +32,9 @@ const POLL_RE = /answered (\d+) of (\d+) poll questions/; const sessionLabel = tx.sessionLabel || ''; if (!sessionLabel) { skipped++; continue; } - const date = tx.dateTime ? new Date(tx.dateTime).toISOString().slice(0, 10) : ''; - const spd = spByEmailDate.get(email + '|' + date); - let attemptedQuestions, totalQuestions; - if (spd) { - attemptedQuestions = spd.attempted; totalQuestions = spd.total; // Spandan participation - } else { - const m = POLL_RE.exec(tx.reason || ''); // legacy Zoom reason - attemptedQuestions = m ? Number(m[1]) : 0; - totalQuestions = m ? Number(m[2]) : 0; - } + const m = POLL_RE.exec(tx.reason || ''); + const attemptedQuestions = m ? Number(m[1]) : 0; + const totalQuestions = m ? Number(m[2]) : 0; const missedQuestions = Math.max(0, totalQuestions - attemptedQuestions); const studentId = studentById.get(email) || null; diff --git a/server/models/Commitment.js b/server/models/Commitment.js deleted file mode 100644 index 950bb91..0000000 --- a/server/models/Commitment.js +++ /dev/null @@ -1,41 +0,0 @@ -import mongoose from 'mongoose'; - -// A commitment (formerly VibeBet) — a stake-a-goal pledge in ANY internship phase. -// One shared collection; `type` selects the phase and which fields apply. One active -// commitment per (email, type). Two economic modes: -// - debited (ViBe): the stake is debited at placement, returned ×multiplier on a HIT. -// - keep (Standup): the stake is NOT debited; a HIT pays a +stake×mult bonus on top -// of the attendance points earned that week, a MISS charges −0.5×stake×mult. -const commitmentSchema = new mongoose.Schema({ - email: { type: String, lowercase: true, trim: true, required: true, index: true }, - type: { type: String, enum: ['vibe', 'standup'], required: true, index: true }, - - // shared economics - stake: { type: Number, required: true }, // 20 / 50 (standup tiers) or 50–200 (vibe) - multiplier: { type: Number, required: true }, // 2 | 3 | 4 - potentialWin: { type: Number, required: true }, // stake * multiplier - potentialLoss: { type: Number, required: true }, // 0.5 * stake * multiplier - reserved: { type: Number, default: 0 }, // SP reserved while active (vibe = loss; standup = 0) - debited: { type: Boolean, default: false }, // was the stake debited at placement (vibe true) - deadline: { type: Date, required: true }, - status: { type: String, enum: ['active', 'won', 'lost'], default: 'active', index: true }, - resultDelta: { type: Number, default: 0 }, - settledAt: { type: Date, default: null }, - label: { type: String, default: '' }, // human summary (for history) - - // ViBe-specific - course: { type: String, default: '' }, // course key - goalPct: { type: Number, default: 0 }, // raise completion by this many % - baselinePct: { type: Number, default: 0 }, // completion % at commit time - - // Standup-specific - tier: { type: String, default: '' }, // '81-90' | '91-100' - tierFloor: { type: Number, default: 0 }, // min average attendance % to hit (81 | 91) - sessionsTarget: { type: Number, default: 0 }, // sessions to attend this week (full week Y) - weekStart: { type: Date, default: null }, - weekEnd: { type: Date, default: null } -}, { timestamps: true }); - -commitmentSchema.index({ email: 1, type: 1, status: 1 }); - -export default mongoose.model('Commitment', commitmentSchema); diff --git a/server/models/JourneyPlan.js b/server/models/JourneyPlan.js deleted file mode 100644 index e6a1aae..0000000 --- a/server/models/JourneyPlan.js +++ /dev/null @@ -1,14 +0,0 @@ -import mongoose from 'mongoose'; - -// A student's self-declared internship plan: target dates to finish each phase. -// Soft goals (no SP staked here — that lives in the commitment/ViBe tab). Hitting -// a planned date can later award a completion bonus. One plan per student. -const journeyPlanSchema = new mongoose.Schema({ - email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true }, - standupBy: { type: Date, default: null }, // reach 3600 cumulative Zoom minutes by - vibeBy: { type: Date, default: null }, // finish all 3 ViBe courses by - spaBy: { type: Date, default: null }, // solve all 53 SPA problems by - projectBy: { type: Date, default: null } // first / target project PR by -}, { timestamps: true }); - -export default mongoose.model('JourneyPlan', journeyPlanSchema); diff --git a/server/models/JourneyProgress.js b/server/models/JourneyProgress.js deleted file mode 100644 index ea49c64..0000000 --- a/server/models/JourneyProgress.js +++ /dev/null @@ -1,19 +0,0 @@ -import mongoose from 'mongoose'; - -// Per-student SPA + Projects progress. PLACEHOLDER source: seeded with dummy values -// locally so the My-Journey cards have numbers. In production these fields will be -// refreshed from Samagama (SPA solver counts / SPA points; project PRs raised & -// merged). The SP-award rule for these two phases is still TBD (decided once the -// real Samagama data shape is known) — that is why sp is not computed here yet. -const journeyProgressSchema = new mongoose.Schema({ - email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true }, - // SPA — Matrix Mystics (53 problems) - spaSolved: { type: Number, default: 0 }, - spaTotal: { type: Number, default: 53 }, - spaPoints: { type: Number, default: 0 }, // existing "SPA points" (separate leaderboard currency) - // Projects — PRs (from Samagama) - prsRaised: { type: Number, default: 0 }, - prsMerged: { type: Number, default: 0 } -}, { timestamps: true }); - -export default mongoose.model('JourneyProgress', journeyProgressSchema); diff --git a/server/models/SPTransaction.js b/server/models/SPTransaction.js index 4bda691..0b4dd92 100644 --- a/server/models/SPTransaction.js +++ b/server/models/SPTransaction.js @@ -6,7 +6,7 @@ const spTransactionSchema = new mongoose.Schema({ category: { type: String, required: true, - enum: ['initial', 'attendance', 'poll', 'manual', 'peer_faq', 'spa', 'query'], + enum: ['initial', 'attendance', 'poll', 'manual'], index: true }, sessionLabel: { type: String, default: '', index: true }, diff --git a/server/models/SpaProgress.js b/server/models/SpaProgress.js deleted file mode 100644 index edd2199..0000000 --- a/server/models/SpaProgress.js +++ /dev/null @@ -1,31 +0,0 @@ -import mongoose from 'mongoose'; - -// Per-student SPA (peer-teaching endorsement) progress for the SPA→SP module. -// Locally seeded with DUMMY values; in production these counts come from the -// `act_spa_*` collections in `sakshi_spurti`: -// learnValidated = # endorsements RECEIVED with status in {approved, audit_passed} -// teachValidated = # endorsements GIVEN with status in {approved, audit_passed} -// auditFail = has a genuine audit_failure_* penalty (viva-failed on review) -// fraud = genuine fraud AFTER netting teacher_fraud_penalty vs -// fraud_penalty_reversal (test/reversed rows are NOT fraud) -// -// SP is credited AUTOMATICALLY as activity arrives — +5 per validated question -// learned, +8 per validated peer taught — via syncSpaSp(). The *Credited fields -// are watermarks of how many events have already been posted to the SP ledger, -// so re-syncs only post the delta (idempotent). No student "claim" step. -const spaProgressSchema = new mongoose.Schema({ - email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true }, - activity: { type: String, default: 'Activity 1: Linear Algebra' }, - learnValidated: { type: Number, default: 0 }, // good endorsements received (from mirror) - teachValidated: { type: Number, default: 0 }, // good endorsements given (from mirror) - learnCredited: { type: Number, default: 0 }, // learns already posted to the ledger - teachCredited: { type: Number, default: 0 }, // teaches already posted to the ledger - auditFail: { type: Boolean, default: false }, // → one-time -20% of current SP - fraud: { type: Boolean, default: false }, // → one-time -50% of current SP - auditPenaltyApplied: { type: Boolean, default: false }, - fraudPenaltyApplied: { type: Boolean, default: false }, - penaltyApplied: { type: Number, default: 0 }, // total SP removed by integrity penalties - penaltyAt: { type: Date, default: null } -}, { timestamps: true }); - -export default mongoose.model('SpaProgress', spaProgressSchema); diff --git a/server/models/Student.js b/server/models/Student.js index d670d1d..1b3e7f2 100644 --- a/server/models/Student.js +++ b/server/models/Student.js @@ -24,10 +24,7 @@ const studentSchema = new mongoose.Schema({ // Second perception pop-up ("poll2") — same mechanism as surveyCompleted, but an // independent flag so it never disturbs the first survey's completion state. poll2Completed: { type: Boolean, default: false, index: true }, - poll2CompletedAt: { type: Date, default: null }, - // Third pop-up ("poll3") — dashboard usability survey. Same mechanism, own flag. - poll3Completed: { type: Boolean, default: false, index: true }, - poll3CompletedAt: { type: Date, default: null } + poll2CompletedAt: { type: Date, default: null } }, { timestamps: true }); studentSchema.index({ name: 'text', email: 'text', alternateEmail: 'text' }); diff --git a/server/models/TrajectorySnapshot.js b/server/models/TrajectorySnapshot.js deleted file mode 100644 index 2cac470..0000000 --- a/server/models/TrajectorySnapshot.js +++ /dev/null @@ -1,18 +0,0 @@ -import mongoose from 'mongoose'; - -// Precomputed average SP trajectories (cumulative SP by week-since-join), so the -// student trajectory chart can show cohort + onboarding-group reference lines -// without aggregating every student's ledger on each page load. One 'latest' doc, -// refreshed by server/scripts/buildTrajectories.js (wire into the analytics cron in prod). -const pointSchema = new mongoose.Schema({ week: Number, sp: Number, n: Number }, { _id: false }); - -const trajectorySnapshotSchema = new mongoose.Schema({ - key: { type: String, default: 'latest', unique: true }, - weeks: { type: Number, default: 10 }, - cohort: { type: [pointSchema], default: [] }, // mean cumulative SP by week, all non-excused students - groups: { type: Object, default: {} }, // { : [{week,sp,n}] } - groupLabels: { type: Object, default: {} }, // { : "1 May to 15 May" } - computedAt: { type: Date } -}, { minimize: false }); - -export default mongoose.model('TrajectorySnapshot', trajectorySnapshotSchema); diff --git a/server/models/VibeProgress.js b/server/models/VibeProgress.js deleted file mode 100644 index 187d90c..0000000 --- a/server/models/VibeProgress.js +++ /dev/null @@ -1,16 +0,0 @@ -import mongoose from 'mongoose'; - -// Per-student ViBe course progress. In production this is refreshed from the ViBe -// leaderboard API (completionPercentage). Here it is seeded with DUMMY values so -// the module can run locally without the live snapshot cron. -const vibeProgressSchema = new mongoose.Schema({ - email: { type: String, lowercase: true, trim: true, required: true, index: true }, - course: { type: String, required: true }, // 'onboarding' | 'ai' | 'mern' - pct: { type: Number, default: 0 }, // completionPercentage 0–100 (from ViBe) - weekHours: { type: Number, default: 0 }, // content-hours done this week (for the floor) - priorCompleted: { type: Boolean, default: false } // credited from a prior program (sheet crosswalk) -}, { timestamps: true }); - -vibeProgressSchema.index({ email: 1, course: 1 }, { unique: true }); - -export default mongoose.model('VibeProgress', vibeProgressSchema); diff --git a/server/scripts/buildTrajectories.js b/server/scripts/buildTrajectories.js deleted file mode 100644 index dc36802..0000000 --- a/server/scripts/buildTrajectories.js +++ /dev/null @@ -1,15 +0,0 @@ -// Recompute the cohort + onboarding-group average SP trajectories and store the -// 'latest' TrajectorySnapshot. Run periodically (wire into the analytics cron in prod). -// node server/scripts/buildTrajectories.js -import mongoose from 'mongoose'; -import { MONGO_URI } from '../config.js'; -import { computeAndStoreTrajectories } from '../services/trajectory.js'; - -async function main() { - await mongoose.connect(MONGO_URI); - const r = await computeAndStoreTrajectories(); - console.log('trajectory snapshot rebuilt:', r); - await mongoose.disconnect(); -} - -main().catch(e => { console.error(e); process.exit(1); }); diff --git a/server/scripts/seedVibeDummy.js b/server/scripts/seedVibeDummy.js deleted file mode 100644 index 2227e28..0000000 --- a/server/scripts/seedVibeDummy.js +++ /dev/null @@ -1,155 +0,0 @@ -// Seed DUMMY 16-July-cohort students so the ViBe Goals tab can be demoed locally. -// Idempotent: upserts by email (all end in @dummy.test) and resets their ViBe rows. -// Existing real students (all onboarded < 16 Jul) stay ineligible and untouched. -// -// node server/scripts/seedVibeDummy.js -import mongoose from 'mongoose'; -import { MONGO_URI } from '../config.js'; -import Student from '../models/Student.js'; -import VibeProgress from '../models/VibeProgress.js'; -import Commitment from '../models/Commitment.js'; -import SPTransaction from '../models/SPTransaction.js'; -import AttendanceRecord from '../models/AttendanceRecord.js'; -import PollRecord from '../models/PollRecord.js'; -import JourneyProgress from '../models/JourneyProgress.js'; -import JourneyPlan from '../models/JourneyPlan.js'; - -const D = (y, m, d) => new Date(Date.UTC(y, m - 1, d)); - -// Build a small SP ledger that ends exactly at `sp`, so the SP Bank has rows. -function ledgerFor(email, sp, startDay) { - const rows = []; let bal = 0; - const push = (category, delta, reason, label, day) => { - bal += delta; - rows.push({ email, category, sessionLabel: label, deltaMode: 'absolute', - deltaValue: delta, appliedDelta: delta, balanceAfter: bal, reason, dateTime: D(2026, 7, day) }); - }; - // insert in strict chronological order so the running balance stays monotonic - push('initial', 100, 'Welcome bonus on joining Summership', '', startDay); - push('attendance', 10, 'Attendance credit — evening session', `${startDay + 1} Jul Evening`, startDay + 1); - push('poll', 5, 'Poll participation', `${startDay + 1} Jul Evening`, startDay + 1); - push('attendance', 10, 'Attendance credit — evening session', `${startDay + 2} Jul Evening`, startDay + 2); - push('attendance', 10, 'Attendance credit — evening session', `${startDay + 3} Jul Evening`, startDay + 3); - const diff = sp - bal; // final adjustment to land exactly on totalSp - if (diff !== 0) push('manual', diff, diff > 0 ? 'Instructor award' : 'Attendance shortfall adjustment', '', startDay + 3); - return rows; -} - -// Build dummy Standup evidence (Zoom attendance + Spandan poll rows) so the Journey -// "Standups" card shows real numbers. std = { sessions, minutes, pollsAttempted, pollsTotal }. -// One AttendanceRecord + one PollRecord per session (PollRecord is a per-session -// aggregate, unique on email+sessionLabel), with the poll questions spread evenly. -function standupRecordsFor(email, studentId, std, startDay) { - const att = [], polls = []; - const spread = (total, n, i) => Math.floor(total / n) + (i < total % n ? 1 : 0); - for (let i = 0; i < std.sessions; i++) { - const label = `${startDay + i} Jul Evening`; - att.push({ email, studentId, sessionLabel: label, attendedMinutes: std.minutes, - totalSessionMinutes: 90, attendancePercentage: Math.round(std.minutes / 90 * 100), qualified: std.minutes >= 68 }); - const tot = spread(std.pollsTotal, std.sessions, i); - const done = Math.min(tot, spread(std.pollsAttempted, std.sessions, i)); - polls.push({ email, studentId, sessionLabel: label, totalQuestions: tot, - attemptedQuestions: done, missedQuestions: tot - done, responses: [] }); - } - return { att, polls }; -} - -// name, email, start, sp, prog (per ViBe course), std (standups), spa/proj (placeholder), plan -const DUMMY = [ - { name: 'Aadhya Rao (dummy)', email: 'aadhya.vibe@dummy.test', start: D(2026,7,16), sp: 300, - prog: { onboarding:{pct:100}, ai:{pct:40, weekHours:1.5}, mern:{pct:0} }, - std: { sessions:5, minutes:82, pollsAttempted:8, pollsTotal:10 }, - spa: { spaSolved:18, spaPoints:220 }, proj: { prsRaised:2, prsMerged:1 }, - plan: { vibeBy: D(2026,8,20), spaBy: D(2026,9,5), projectBy: D(2026,8,28) } }, - { name: 'Vihaan Menon (dummy)', email: 'vihaan.vibe@dummy.test', start: D(2026,7,17), sp: 150, - prog: { onboarding:{pct:100}, ai:{pct:10, weekHours:0.5}, mern:{pct:0} }, - std: { sessions:3, minutes:64, pollsAttempted:3, pollsTotal:8 }, - spa: { spaSolved:6, spaPoints:70 }, proj: { prsRaised:0, prsMerged:0 }, - plan: { vibeBy: D(2026,9,1), spaBy: null, projectBy: null } }, - { name: 'Diya Nair (dummy)', email: 'diya.vibe@dummy.test', start: D(2026,7,18), sp: 120, - prog: { onboarding:{pct:60, weekHours:1.2}, ai:{pct:0}, mern:{pct:0} }, - std: { sessions:4, minutes:78, pollsAttempted:5, pollsTotal:6 }, - spa: { spaSolved:2, spaPoints:20 }, proj: { prsRaised:0, prsMerged:0 }, - plan: { vibeBy: null, spaBy: null, projectBy: null } }, - { name: 'Arjun Iyer (dummy)', email: 'arjun.vibe@dummy.test', start: D(2026,7,20), sp: 500, - prog: { onboarding:{pct:100}, ai:{pct:100}, mern:{pct:20, weekHours:2} }, - std: { sessions:6, minutes:88, pollsAttempted:11, pollsTotal:12 }, - spa: { spaSolved:41, spaPoints:530 }, proj: { prsRaised:5, prsMerged:4 }, - plan: { vibeBy: D(2026,8,10), spaBy: D(2026,8,25), projectBy: D(2026,8,15) } }, - { name: 'Kabir Shah (dummy)', email: 'kabir.vibe@dummy.test', start: D(2026,7,22), sp: 200, - prog: { onboarding:{pct:100}, ai:{prior:true}, mern:{pct:5, weekHours:1} }, - std: { sessions:2, minutes:71, pollsAttempted:2, pollsTotal:4 }, - spa: { spaSolved:9, spaPoints:110 }, proj: { prsRaised:1, prsMerged:0 }, - plan: { vibeBy: D(2026,8,31), spaBy: D(2026,9,10), projectBy: null } } -]; - -async function main() { - await mongoose.connect(MONGO_URI); - const emails = DUMMY.map(d => d.email); - await Promise.all([ - Commitment.deleteMany({ email: { $in: emails } }), - VibeProgress.deleteMany({ email: { $in: emails } }), - SPTransaction.deleteMany({ email: { $in: emails } }), - AttendanceRecord.deleteMany({ email: { $in: emails } }), - PollRecord.deleteMany({ email: { $in: emails } }), - JourneyProgress.deleteMany({ email: { $in: emails } }), - JourneyPlan.deleteMany({ email: { $in: emails } }) - ]); - - for (const d of DUMMY) { - await Student.updateOne( - { email: d.email }, - { $set: { - name: d.name, email: d.email, internshipStartDate: d.start, - status: 'active', totalSp: d.sp, highestSpEver: d.sp, - level: 1, trophyLeague: 'Bronze II', leaderboardGroup: '2026-07-16' - } }, - { upsert: true } - ); - const stu = await Student.findOne({ email: d.email }).lean(); - for (const [course, p] of Object.entries(d.prog)) { - await VibeProgress.updateOne( - { email: d.email, course }, - { $set: { pct: p.pct ?? 0, weekHours: p.weekHours ?? 0, priorCompleted: !!p.prior } }, - { upsert: true } - ); - } - await SPTransaction.insertMany(ledgerFor(d.email, d.sp, d.start.getUTCDate())); - - // Standups — attendance + poll evidence for the Journey card - const { att, polls } = standupRecordsFor(d.email, stu._id, d.std, d.start.getUTCDate()); - if (att.length) await AttendanceRecord.insertMany(att); - if (polls.length) await PollRecord.insertMany(polls); - - // SPA + Projects placeholder progress, and the self-declared plan - await JourneyProgress.updateOne({ email: d.email }, - { $set: { spaSolved: d.spa.spaSolved, spaTotal: 53, spaPoints: d.spa.spaPoints, - prsRaised: d.proj.prsRaised, prsMerged: d.proj.prsMerged } }, { upsert: true }); - await JourneyPlan.updateOne({ email: d.email }, - { $set: { vibeBy: d.plan.vibeBy, spaBy: d.plan.spaBy, projectBy: d.plan.projectBy } }, { upsert: true }); - } - - // A little settled history so the "Past" tables aren't empty. - await Commitment.create({ - email: 'aadhya.vibe@dummy.test', type: 'vibe', debited: true, - course: 'ai', goalPct: 20, baselinePct: 20, - deadline: D(2026,7,18), stake: 100, multiplier: 2, - potentialWin: 200, potentialLoss: 100, reserved: 0, - label: '+20% Fundamentals of AI (stake 100 @ 2×)', - status: 'won', resultDelta: 200, settledAt: D(2026,7,18) - }); - // A settled standup commitment for Arjun (keep-the-stake: HIT credited the +150 bonus). - await Commitment.create({ - email: 'arjun.vibe@dummy.test', type: 'standup', debited: false, reserved: 0, - stake: 50, multiplier: 3, potentialWin: 150, potentialLoss: 75, - tier: '91-100', tierFloor: 91, sessionsTarget: 6, - label: 'Attend all 6 standups @ 91–100% (3×)', - deadline: D(2026,7,19), status: 'won', resultDelta: 150, settledAt: D(2026,7,19) - }); - - console.log(`Seeded ${DUMMY.length} dummy 16-July students:`); - DUMMY.forEach(d => console.log(` ${d.email} (start ${d.start.toISOString().slice(0,10)}, ${d.sp} SP)`)); - await mongoose.disconnect(); -} - -main().catch(e => { console.error(e); process.exit(1); }); diff --git a/server/server.js b/server/server.js index bdb062a..75abbd9 100644 --- a/server/server.js +++ b/server/server.js @@ -14,24 +14,12 @@ 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 Commitment from './models/Commitment.js'; -import { isVibeEligible, buildVibeState, validateBet, settleBetDemo, applySpDelta, courseByKey } from './services/vibe.js'; -import { buildStandupState, placeStandup, settleStandupDemo } from './services/standup.js'; -import { buildJourneyState, saveJourneyPlan } from './services/journey.js'; -import { buildSpaState } from './services/spa.js'; -import { buildTrajectoryState } from './services/trajectory.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); const clientDist = path.join(rootDir, 'client', 'dist'); -// Admin auth is env-only — NO hardcoded fallback. A committed default would be a -// public credential (anyone reading the repo could authenticate). If either is -// unset, admin endpoints fail closed (see isAdmin) rather than accept a known value. -const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || ''); -const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ''; -if (!ADMIN_EMAIL || !ADMIN_TOKEN) { - console.warn('[security] ADMIN_EMAIL/ADMIN_TOKEN not set — admin endpoints are DISABLED until both are configured in .env'); -} +const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || 'dled@iitrpr.ac.in'); +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'vled-local-admin'; // Survey triangulation pop-up(s). All driven by env so the form link / mode can // change without a client rebuild (the client reads these via /api/config). @@ -62,8 +50,7 @@ function makeSurvey(prefix, completedField) { } const SURVEY = makeSurvey('SURVEY', 'surveyCompleted'); const POLL2 = makeSurvey('POLL2', 'poll2Completed'); -const POLL3 = makeSurvey('POLL3', 'poll3Completed'); -const SURVEYS = [SURVEY, POLL2, POLL3]; +const SURVEYS = [SURVEY, POLL2]; // Cached fetch of the submitted-email set from a survey's Apps Script endpoint. async function getSubmittedEmails(cfg) { @@ -73,17 +60,10 @@ async function getSubmittedEmails(cfg) { const u = cfg.responsesUrl + (cfg.responsesUrl.includes('?') ? '&' : '?') + 'secret=' + encodeURIComponent(cfg.responsesSecret); const r = await fetch(u, { redirect: 'follow' }); - // Apps Script intermittently serves an HTML error/redirect page (esp. under - // load) instead of JSON; parse defensively so it fails cleanly instead of - // throwing an opaque "Unexpected token '<'". - const body = await r.text(); - let j; - try { j = JSON.parse(body); } - catch { throw new Error(`non-JSON response (HTTP ${r.status}, ${body.length}B)`); } + const j = await r.json(); cfg._subs = { at: Date.now(), set: new Set((j.emails || []).map(e => normalizeEmail(e))) }; return cfg._subs.set; } catch (err) { - cfg._subs.at = Date.now(); // back off 60s on failure too — don't hammer Apps Script / spam logs console.error(`${cfg.key} responses fetch failed:`, err?.message); return cfg._subs.set; // serve last good cache on failure } @@ -246,9 +226,7 @@ async function studentPayload(student) { leaderboardGroup: myGroup, leaderboardGroupLabel: groupLabel(myGroup), surveyCompleted: Boolean(student.surveyCompleted), - poll2Completed: Boolean(student.poll2Completed), - poll3Completed: Boolean(student.poll3Completed), - eligibleForVibeGoals: isVibeEligible(student) + poll2Completed: Boolean(student.poll2Completed) }, transactions, polls, @@ -266,7 +244,6 @@ async function studentPayload(student) { } function isAdmin(req) { - if (!ADMIN_EMAIL || !ADMIN_TOKEN) return false; // fail closed when admin creds aren't configured const emailOk = normalizeEmail(req.headers['x-admin-email']) === ADMIN_EMAIL; const tokenOk = String(req.headers['x-admin-token'] || '') === ADMIN_TOKEN; return emailOk && tokenOk; @@ -282,8 +259,7 @@ api.get('/health', (_req, res) => res.json({ status: 'ok' })); api.get('/config', (_req, res) => res.json({ allowStudentSearch: ALLOW_STUDENT_SEARCH, survey: surveyPublic(SURVEY), - poll2: surveyPublic(POLL2), - poll3: surveyPublic(POLL3) + poll2: surveyPublic(POLL2) })); api.get('/me', async (req, res) => { @@ -295,97 +271,6 @@ api.get('/me', async (req, res) => { res.json({ authenticated: true, profile: await studentPayload(student) }); }); -// ---- ViBe Goals (commitment-SP module; 16 July cohort onward) ---------------- -async function vibeStudent(req) { - const email = normalizeEmail(req.body?.email || req.query.email) || await studentEmailFromRequest(req); - if (!email) return null; - return Student.findOne({ $or: [{ email }, { alternateEmail: email }] }).lean(); -} - -api.get('/vibe/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - if (!isVibeEligible(student)) return res.json({ eligible: false }); - res.json(await buildVibeState(student)); -}); - -api.post('/vibe/bet', async (_req, res) => { - // ON HOLD: ViBe commitments are paused — the ViBe completion feed (leaderboard API) - // is unavailable, so bets can't be verified or settled. No new bets can be placed - // (nothing is staked/debited) until the feed is restored. - return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' }); -}); - -api.put('/vibe/bet/:id', async (_req, res) => { - // ON HOLD: see POST /vibe/bet. - return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' }); -}); - -// DEMO: resolve a bet (no live settlement cron locally). result = 'won' | 'lost'. -api.post('/vibe/bet/:id/settle', async (_req, res) => { - // LOCKED DOWN (security): client-controlled self-settlement is removed. This route - // trusted req.body.result (defaulting to "won") and granted SP with NO check against - // real ViBe course completion — students could place a bet and instantly self-declare - // a win to mint SP. There is no real completion feed (VibeProgress.pct was written by - // settleBetDemo itself), so settlement cannot be verified yet; disabled until a - // server-side/automatic settlement against real completion data is built. - return res.status(403).json({ error: 'Bets are settled automatically, not on request. Self-settlement is disabled.' }); -}); - -// ---- SPA → SP (peer-teaching endorsement points; ALL cohorts) ---------------- -// DISPLAY ONLY: SP is scored + credited by the pipeline rubric; this just reads -// the `spaprogresses` summary + student total. Universal, no cohort gate. -api.get('/spa/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildSpaState(student)); -}); - -// ---- SP trajectory (You vs cohort vs onboarding-group; open to all students) -- -// The student's own weekly line is built live from their ledger; the cohort/group -// reference lines come from the cached TrajectorySnapshot (buildTrajectories.js). -api.get('/trajectory/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildTrajectoryState(student)); -}); - -// ---- Standup commitments (weekly, attendance-only; keep-the-stake) ----------- -api.get('/standup/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - if (!isVibeEligible(student)) return res.json({ eligible: false }); - res.json(await buildStandupState(student)); -}); - -api.post('/standup/commit', async (_req, res) => { - // PAUSED: standups moved to YouTube Live and the attendance module is being - // reworked — no new standup commitments until the new attendance tracking lands. - return res.status(403).json({ error: 'Standup commitments are paused while attendance is reworked for YouTube Live.' }); -}); - -// DEMO: resolve a standup commitment (no live weekly settlement cron yet). -api.post('/standup/commit/:id/settle', async (_req, res) => { - // LOCKED DOWN (security): same self-settlement exploit as /vibe/bet/:id/settle — - // client-declared "won" minted SP with no verification. Disabled until server-side - // settlement against real attendance/completion is built. - return res.status(403).json({ error: 'Commitments are settled automatically, not on request. Self-settlement is disabled.' }); -}); - -// ---- My Journey (phase-by-phase progress + SP; 16 July cohort onward) --------- -api.get('/journey/state', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); - res.json(await buildJourneyState(student)); // My Journey is universal (Phase 1); Commitments stays gated -}); - -api.put('/journey/plan', async (req, res) => { - const student = await vibeStudent(req); - if (!student) return res.status(404).json({ error: 'Student not found' }); // My Journey goals are universal (Phase 1) - await saveJourneyPlan(student.email, req.body || {}); - res.json(await buildJourneyState(student)); -}); - api.get('/search', async (req, res) => { if (!ALLOW_STUDENT_SEARCH) return res.status(403).json({ error: 'Student search is disabled. Please login from Samagama to view your Spurti Points.' }); const q = String(req.query.q || '').trim(); @@ -513,7 +398,6 @@ function registerSurveyRoutes(base, cfg) { } registerSurveyRoutes('/survey', SURVEY); registerSurveyRoutes('/poll2', POLL2); -registerSurveyRoutes('/poll3', POLL3); api.get('/admin/stats', adminGuard, async (_req, res) => { const [yetToOnboard, excusedStudents, sessions, txns, activeStudents] = await Promise.all([ diff --git a/server/services/journey.js b/server/services/journey.js deleted file mode 100644 index 16e5019..0000000 --- a/server/services/journey.js +++ /dev/null @@ -1,182 +0,0 @@ -// "My Journey" — the unified phase-by-phase progress + SP view (16 July cohort). -// Four phases: (1) Standups = Zoom attendance + Spandan polls, (2) ViBe = 3 courses, -// (3) SPA = Matrix Mystics 53 problems, (4) Projects = PRs. -// -// SP attribution per phase: -// - Standups: ALREADY awarded (attendance + poll SPTransactions) — we just aggregate. -// - ViBe: net SP from settled commitments (+ the weekly floor) — from the ViBe module. -// - SPA / Projects: rule TBD until Samagama data lands — shown as "coming soon" (sp = 0). -import AttendanceRecord from '../models/AttendanceRecord.js'; -import PollRecord from '../models/PollRecord.js'; -import SPTransaction from '../models/SPTransaction.js'; -import Commitment from '../models/Commitment.js'; -import JourneyPlan from '../models/JourneyPlan.js'; -import JourneyProgress from '../models/JourneyProgress.js'; -import { buildVibeState, isVibeEligible } from './vibe.js'; - -export const SPA_TOTAL = 53; -export const STANDUP_MINUTES_TARGET = 3600; // cumulative Zoom minutes (~120 min/week) - -export async function buildJourneyState(student) { - const email = student.email; - - // --- Phase 1: Standups (attendance + Spandan polls) — existing SP, aggregated --- - const [att, polls, txns] = await Promise.all([ - AttendanceRecord.find({ email }).lean(), - PollRecord.find({ email }).lean(), - SPTransaction.find({ email }).lean() - ]); - const spByCat = cats => txns - .filter(t => cats.includes(t.category)) - .reduce((a, t) => a + (t.appliedDelta || 0), 0); - const standups = { - zoomMinutes: att.reduce((a, r) => a + (r.attendedMinutes || 0), 0), - sessionsAttended: att.filter(r => (r.attendedMinutes || 0) > 0).length, - pollSessions: polls.length, - pollsAttempted: polls.reduce((a, p) => a + (p.attemptedQuestions || 0), 0), - pollsTotal: polls.reduce((a, p) => a + (p.totalQuestions || 0), 0), - spAttendance: spByCat(['attendance']), - spPolls: spByCat(['poll']) - }; - standups.sp = standups.spAttendance + standups.spPolls; - - // --- Phase 2: ViBe (3 courses) — summarise the commitment module --- - const v = await buildVibeState(student); - const settled = await Commitment.find({ email, type: 'vibe', status: { $in: ['won', 'lost'] } }).lean(); - // Pre-16-July students never had the ViBe Onboarding course — show them AI + MERN only. - const ladder = isVibeEligible(student) ? v.ladder : v.ladder.filter(l => l.key !== 'onboarding'); - const vibe = { - ladder, - current: v.current, - clearedCount: ladder.filter(l => l.cleared).length, - totalCourses: ladder.length, - activeCommitment: v.active - ? { course: v.active.course, goalPct: v.active.goalPct, deadline: v.active.deadline } - : null, - settledCount: settled.length, - sp: settled.reduce((a, b) => a + (b.resultDelta || 0), 0) // net SP from settled commitments - }; - - // --- Phase 3 & 4: SPA + Projects — PLACEHOLDER (Samagama data + SP rule TBD) --- - const jp = (await JourneyProgress.findOne({ email }).lean()) || {}; - const spa = { - solved: jp.spaSolved || 0, - total: jp.spaTotal || SPA_TOTAL, - spaPoints: jp.spaPoints || 0, - sp: 0, pending: true // SP rule decided once Samagama data arrives - }; - const projects = { - prsRaised: jp.prsRaised || 0, - prsMerged: jp.prsMerged || 0, - sp: 0, pending: true // SP rule decided once Samagama data arrives - }; - - const plan = await JourneyPlan.findOne({ email }).lean(); - - // Per-phase goal status + pace toward the student's target date. ViBe has live - // completion %; SPA/Projects are pending (date countdown only until Samagama data). - const vibeOverall = Math.round(vibe.ladder.reduce((a, l) => a + l.pct, 0) / (vibe.ladder.length || 1)); - const goals = { - standup: phaseGoal({ targetDate: plan?.standupBy, current: standups.zoomMinutes, target: STANDUP_MINUTES_TARGET, unit: 'min', daysPerWeek: STANDUP_DAYS_PER_WEEK }), - vibe: phaseGoal({ targetDate: plan?.vibeBy, current: vibeOverall, target: 100, unit: '%' }), - spa: phaseGoal({ targetDate: plan?.spaBy, pending: true }), - project: phaseGoal({ targetDate: plan?.projectBy, pending: true }) - }; - // Attach realistic date bounds so a target can't be set too soon (superficial) or - // too far out (delayed). Standups is pace-capped at ~60 min per working day. - const endDate = student.internshipEndDate || null; - for (const [key, g] of Object.entries(goals)) Object.assign(g, goalBounds(key, g, endDate)); - - return { - eligible: true, - name: student.name, - totalSp: student.totalSp || 0, - plan: { - standupBy: plan?.standupBy || null, - vibeBy: plan?.vibeBy || null, - spaBy: plan?.spaBy || null, - projectBy: plan?.projectBy || null - }, - goals, - phaseSp: { standups: standups.sp, vibe: vibe.sp, spa: spa.sp, projects: projects.sp }, - standups, vibe, spa, projects - }; -} - -const DAY_MS = 86400000; -const startOfToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }; -const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; }; -// Local date-only string (yyyy-mm-dd) — matches how works and -// avoids UTC/local off-by-one between the picker min/max and server validation. -const ymd = d => { const x = new Date(d); return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`; }; -export const STANDUP_MIN_PER_DAY = 60; // one 60-min standup per working day -export const STANDUP_DAYS_PER_WEEK = 6; // 6 working days per week - -// Realistic [minDate, maxDate] for a goal. Standups are pace-capped (can't beat -// 60 min/working-day over 6 working days/week); other phases just need a small buffer -// so it isn't "tomorrow". Upper bound = internship end if known, else 60 days past min. -function goalBounds(key, goal, endDate) { - const today = startOfToday(); - let minDate; - if (key === 'standup' && goal.remaining > 0) { - const workDays = Math.ceil(goal.remaining / STANDUP_MIN_PER_DAY); - minDate = addDays(today, Math.ceil((workDays * 7) / STANDUP_DAYS_PER_WEEK)); // working → calendar days - } else { - minDate = addDays(today, key === 'project' ? 3 : 7); - } - const end = endDate ? new Date(endDate) : null; - const maxDate = end && end.getTime() > minDate.getTime() ? end : addDays(minDate, 60); - return { minDate: ymd(minDate), maxDate: ymd(maxDate), paceHint: key === 'standup' ? '≈60 min per working day' : null }; -} - -// Goal status + pace for one phase. `current`/`target` are in `unit` (e.g. min, %). -// none | active | achieved | missed. pending = progress data not available yet. -function phaseGoal({ targetDate, current = null, target = null, unit = '%', pending = false, daysPerWeek = null }) { - const t = targetDate ? new Date(targetDate) : null; - const today = startOfToday(); - const known = !pending && current != null && target != null; - const complete = known && current >= target; - let status = 'none'; - if (t) { - if (complete) status = 'achieved'; - else if (t.getTime() < today.getTime()) status = 'missed'; - else status = 'active'; - } - const daysLeft = t ? Math.max(0, Math.round((t.getTime() - today.getTime()) / DAY_MS)) : null; - const progressPct = known ? Math.min(100, Math.round((current / target) * 100)) : null; - const remaining = known ? Math.max(0, target - current) : null; - // Pace to stay on track. For a phase with working days (standups), pace is per - // WORKING day over the working days left; otherwise per calendar day. - let perDay = null, perDayUnit = 'day'; - if (status === 'active' && remaining != null && daysLeft > 0) { - if (daysPerWeek) { - const workDaysLeft = Math.max(1, Math.round((daysLeft * daysPerWeek) / 7)); - perDay = Math.ceil(remaining / workDaysLeft); - perDayUnit = 'working day'; - } else { - perDay = Math.ceil(remaining / daysLeft); - } - } - return { - targetDate: t, hasTarget: !!t, status, unit, - current: known ? current : null, target: known ? target : null, - progressPct, remaining, remainingPct: progressPct == null ? null : Math.max(0, 100 - progressPct), - daysLeft, perDay, perDayUnit, pending: !!pending - }; -} - -// Upsert the plan dates — but LOCK a phase once its target is set and still in the -// future (a commitment device: no moving the goalpost). A phase only becomes settable -// again when its date has passed (missed). Only fields present in `incoming` are touched. -export async function saveJourneyPlan(email, incoming = {}) { - const existing = await JourneyPlan.findOne({ email }).lean(); - const today = startOfToday(); - const set = {}; - for (const f of ['standupBy', 'vibeBy', 'spaBy', 'projectBy']) { - const cur = existing?.[f] ? new Date(existing[f]) : null; - const locked = cur && cur.getTime() >= today.getTime(); // active future target → locked - if (locked) continue; - if (Object.prototype.hasOwnProperty.call(incoming, f)) set[f] = incoming[f] ? new Date(incoming[f]) : null; - } - if (Object.keys(set).length) await JourneyPlan.updateOne({ email }, { $set: set }, { upsert: true }); -} diff --git a/server/services/spa.js b/server/services/spa.js deleted file mode 100644 index be4cafa..0000000 --- a/server/services/spa.js +++ /dev/null @@ -1,61 +0,0 @@ -// SPA → SP module logic (peer-teaching endorsement activity) — DISPLAY layer. -// The SP itself is scored + credited by the pipeline rubric -// (pipeline/sp-rubric-build-mirror.cjs, Pattern A): +5 per validated question -// learned, +8 per validated peer taught (capped 50/30), minus a one-time -// audit/fraud penalty. The rubric writes the `spaprogresses` summary this reads. -// The web app NEVER writes SP — it only renders what the rubric produced. -// Universal: applies to ALL cohorts (15-May onward). -import Student from '../models/Student.js'; -import SpaProgress from '../models/SpaProgress.js'; - -export function isSpaEligible() { return true; } // SPA SP is a universal feature. - -// LOCKED scheme (see memory: spa-sp-award-scheme). -export const CONFIG = { - learnUnit: 5, learnCap: 50, // +5 SP per validated question learned, cap 50 → max 250 - teachUnit: 8, teachCap: 30, // +8 SP per validated endorsement given, cap 30 → max 240 - fraudRate: 0.5, // genuine fraud → -50% of current SP (one-time) - auditRate: 0.2 // audit failure → -20% of current SP (one-time) -}; -export const MAX_SPA_SP = CONFIG.learnUnit * CONFIG.learnCap + CONFIG.teachUnit * CONFIG.teachCap; // 490 - -// Pure computation of the SP breakdown from a SpaProgress row (display). -export function computeSpaSp(prog) { - const learnCounted = Math.min(prog.learnValidated || 0, CONFIG.learnCap); - const teachCounted = Math.min(prog.teachValidated || 0, CONFIG.teachCap); - return { - learn: { validated: prog.learnValidated || 0, counted: learnCounted, credited: prog.learnCredited || 0, - cap: CONFIG.learnCap, unit: CONFIG.learnUnit, sp: learnCounted * CONFIG.learnUnit }, - teach: { validated: prog.teachValidated || 0, counted: teachCounted, credited: prog.teachCredited || 0, - cap: CONFIG.teachCap, unit: CONFIG.teachUnit, sp: teachCounted * CONFIG.teachUnit }, - grossSp: learnCounted * CONFIG.learnUnit + teachCounted * CONFIG.teachUnit, - penalty: { fraud: !!prog.fraud, auditFail: !!prog.auditFail, - rate: prog.fraud ? CONFIG.fraudRate : (prog.auditFail ? CONFIG.auditRate : 0), - applied: prog.penaltyApplied || 0, at: prog.penaltyAt || null, - done: (prog.penaltyApplied || 0) > 0 } - }; -} - -// Build the student-facing SPA state. DISPLAY ONLY — the SP itself is scored and -// credited by the pipeline rubric (sp-rubric-build-mirror.cjs, Pattern A), which -// also writes the `spaprogresses` summary this reads. The web app never writes SP. -export async function buildSpaState(student) { - const prog = await SpaProgress.findOne({ email: student.email }).lean(); - const stu = await Student.findOne({ email: student.email }).lean(); - if (!prog) { - return { eligible: true, name: student.name, totalSp: stu?.totalSp || 0, - activity: 'Activity 1: Linear Algebra', hasActivity: false, config: CONFIG, maxSp: MAX_SPA_SP }; - } - const calc = computeSpaSp(prog); - return { - eligible: true, - name: student.name, - totalSp: stu?.totalSp || 0, - activity: prog.activity, - hasActivity: (prog.learnValidated || 0) + (prog.teachValidated || 0) > 0, - ...calc, - creditedSp: calc.learn.credited * CONFIG.learnUnit + calc.teach.credited * CONFIG.teachUnit, - maxSp: MAX_SPA_SP, - config: CONFIG - }; -} diff --git a/server/services/standup.js b/server/services/standup.js deleted file mode 100644 index 4975e5b..0000000 --- a/server/services/standup.js +++ /dev/null @@ -1,114 +0,0 @@ -// Standup commitment — a WEEKLY, attendance-only pledge (polls stay as poll-points). -// The student pledges to attend ALL of this week's standups at a chosen attendance -// tier, with a confidence multiplier. "Keep-the-stake" economics: the stake is NOT -// debited (it represents the attendance points you earn that week); a HIT pays a -// +stake×mult bonus on top of your earned attendance, a MISS charges −0.5×stake×mult. -// -// Anti-mining: the tier floor is ≥81% and the pledge is the FULL week — you can't -// farm SP by pledging a low bar or a single session. -import Student from '../models/Student.js'; -import AttendanceRecord from '../models/AttendanceRecord.js'; -import Commitment from '../models/Commitment.js'; -import { applySpDelta } from './vibe.js'; - -export const STANDUP = { - sessionsPerWeek: 6, // Y — standups scheduled per week (6/6) - multipliers: [2, 3, 4], - penaltyFactor: 0.5, - // Two attendance tiers. Higher tier = higher bar (≥91%) and a larger stake cap, - // to nudge students toward consistent 91–100% attendance. - tiers: [ - { key: '81-90', label: '81–90%', floor: 81, stake: 20 }, - { key: '91-100', label: '91–100%', floor: 91, stake: 50 } - ] -}; - -export const tierByKey = k => STANDUP.tiers.find(t => t.key === k); - -// Current calendar week, Monday 00:00 → Sunday 23:59:59 (local server time). -function weekWindow(now = new Date()) { - const start = new Date(now); start.setHours(0, 0, 0, 0); - const dow = (start.getDay() + 6) % 7; // 0 = Monday - start.setDate(start.getDate() - dow); - const end = new Date(start); end.setDate(end.getDate() + 6); end.setHours(23, 59, 59, 999); - return { start, end }; -} -const fmt = d => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); - -export async function buildStandupState(student) { - const email = student.email; - const { start, end } = weekWindow(); - - // Attendance already logged this week (informational — the demo settles by button). - const wk = await AttendanceRecord.find({ email }).lean(); - const thisWeek = wk.filter(r => r.createdAt && new Date(r.createdAt) >= start && new Date(r.createdAt) <= end); - const attendedThisWeek = thisWeek.filter(r => (r.attendedMinutes || 0) > 0).length; - const avgPctThisWeek = attendedThisWeek - ? Math.round(thisWeek.reduce((a, r) => a + (r.attendancePercentage || 0), 0) / attendedThisWeek) - : null; - - const commits = await Commitment.find({ email, type: 'standup' }).sort({ createdAt: -1 }).lean(); - const active = commits.find(c => c.status === 'active') || null; - const history = commits.filter(c => c.status !== 'active'); - const reserved = active ? active.reserved : 0; - const available = Math.max(0, (student.totalSp || 0) - reserved); - - return { - eligible: true, - name: student.name, - weekLabel: `${fmt(start)} – ${fmt(end)}`, - deadline: end, - sessionsThisWeek: STANDUP.sessionsPerWeek, - attendedThisWeek, avgPctThisWeek, - tiers: STANDUP.tiers, multipliers: STANDUP.multipliers, penaltyFactor: STANDUP.penaltyFactor, - totalSp: student.totalSp || 0, available, - active, history - }; -} - -// Validate a standup pledge. Stake is FIXED at the tier cap (not chosen). Returns -// { errs, win, loss, stake, tier, deadline, label }. -export function validateStandup({ state, tierKey, multiplier }) { - const errs = []; - const tier = tierByKey(tierKey); - if (!tier) errs.push('Pick an attendance tier.'); - if (!STANDUP.multipliers.includes(multiplier)) errs.push('Invalid confidence multiplier.'); - if (state.active) errs.push('You already have an active standup commitment this week.'); - - const stake = tier ? tier.stake : 0; - const win = stake * multiplier; - const loss = STANDUP.penaltyFactor * stake * multiplier; - // Keep-the-stake: nothing is debited now, but a MISS charges the penalty — so the - // student must be able to cover the potential loss (SP never goes negative). - if (loss > state.available) { - errs.push(`You need ${loss} SP free to cover a possible miss (−${loss}); you have ${state.available}.`); - } - const label = tier ? `Attend all ${state.sessionsThisWeek} standups @ ${tier.label} (${multiplier}×)` : ''; - return { errs, win, loss, stake, tier, deadline: state.deadline, label }; -} - -export async function placeStandup(student, { tierKey, multiplier }) { - const state = await buildStandupState(student); - const v = validateStandup({ state, tierKey, multiplier: +multiplier }); - if (v.errs.length) return { error: v.errs.join(' ') }; - const { start, end } = weekWindow(); - await Commitment.create({ - email: student.email, type: 'standup', debited: false, reserved: 0, - stake: v.stake, multiplier: +multiplier, potentialWin: v.win, potentialLoss: v.loss, - tier: v.tier.key, tierFloor: v.tier.floor, sessionsTarget: state.sessionsThisWeek, - weekStart: start, weekEnd: end, deadline: end, label: v.label, status: 'active' - }); - return { ok: true }; -} - -// DEMO: resolve a standup commitment (no live weekly settlement cron yet). Keep-the- -// stake: a HIT credits +potentialWin, a MISS debits −potentialLoss. No prior debit to -// reconcile. In production this is judged automatically at week's end: -// HIT ⇔ sessions attended ≥ target AND average attendance % ≥ tier floor. -export async function settleStandupDemo(commitment, result) { - const delta = result === 'won' ? commitment.potentialWin : -commitment.potentialLoss; - await applySpDelta(commitment.email, delta, - `Standup goal ${result === 'won' ? 'HIT' : 'MISS'}: ${commitment.label}`); - await Commitment.updateOne({ _id: commitment._id }, - { $set: { status: result, resultDelta: delta, settledAt: new Date() } }); -} diff --git a/server/services/trajectory.js b/server/services/trajectory.js deleted file mode 100644 index f75cebb..0000000 --- a/server/services/trajectory.js +++ /dev/null @@ -1,100 +0,0 @@ -// Student SP trajectories — cumulative SP by WEEK SINCE JOIN (normalised onset, so -// students who joined on different dates are comparable; matches the trajectory-paper -// framing). Three lines on the chart: You / Cohort mean / your Onboarding-group mean. -// Cohort & group means are precomputed (see buildTrajectories.js); the student's own -// line is built live from their ledger. -import Student from '../models/Student.js'; -import SPTransaction from '../models/SPTransaction.js'; -import TrajectorySnapshot from '../models/TrajectorySnapshot.js'; -import { leaderboardGroup, groupLabel } from './levels.js'; - -export const WEEKS = 10; // cap the axis at ~10 weeks (typical internship length) -const WEEK_MS = 7 * 86400000; - -// Balance at time t = balanceAfter of the last txn on/before t (0 before any txn). -// txns must be sorted ascending by dateTime. -function balanceAt(txns, t) { - let bal = 0; - for (const tx of txns) { - if (new Date(tx.dateTime).getTime() <= t) bal = tx.balanceAfter; else break; - } - return bal; -} - -// Cumulative SP at the end of each COMPLETED week since join. Week is 1-indexed. -function weeklySeries(txns, joinMs, nowMs) { - const out = []; - for (let w = 0; w < WEEKS; w++) { - const boundary = joinMs + (w + 1) * WEEK_MS; - if (nowMs < boundary) break; // this week isn't complete yet - out.push({ week: w + 1, sp: balanceAt(txns, boundary) }); - } - return out; -} - -// Average per week, dropping weeks with too few students (the noisy small-sample tail — -// few learners reach the last weeks). Keep weeks with n >= max(15, 10% of week-1's count). -function toSeries(acc) { - const first = acc.find(x => x.n > 0); - const floor = first ? Math.max(15, Math.round(first.n * 0.1)) : 15; - return acc - .map((x, i) => ({ week: i + 1, sp: x.n ? Math.round(x.sum / x.n) : null, n: x.n })) - .filter(p => p.sp !== null && p.n >= floor); -} - -// Recompute cohort + per-group average trajectories and upsert the 'latest' snapshot. -export async function computeAndStoreTrajectories(now = new Date()) { - const nowMs = now.getTime(); - const students = await Student.find({ status: { $ne: 'excused' }, internshipStartDate: { $ne: null } }) - .select('email internshipStartDate').lean(); - - const txns = await SPTransaction.find({}).select('email dateTime balanceAfter').sort({ dateTime: 1 }).lean(); - const byEmail = new Map(); - for (const t of txns) { const a = byEmail.get(t.email); if (a) a.push(t); else byEmail.set(t.email, [t]); } - - const blank = () => Array.from({ length: WEEKS }, () => ({ sum: 0, n: 0 })); - const cohort = blank(); - const groups = new Map(); - - for (const s of students) { - const joinMs = new Date(s.internshipStartDate).getTime(); - if (joinMs > nowMs) continue; // not started yet - const series = weeklySeries(byEmail.get(s.email) || [], joinMs, nowMs); - if (!series.length) continue; - const gk = leaderboardGroup(s.internshipStartDate); - if (!groups.has(gk)) groups.set(gk, blank()); - const garr = groups.get(gk); - for (const p of series) { - const i = p.week - 1; - cohort[i].sum += p.sp; cohort[i].n++; - garr[i].sum += p.sp; garr[i].n++; - } - } - - const groupsObj = {}, groupLabels = {}; - for (const [gk, arr] of groups) { groupsObj[gk] = toSeries(arr); groupLabels[gk] = groupLabel(gk); } - - await TrajectorySnapshot.updateOne({ key: 'latest' }, - { $set: { weeks: WEEKS, cohort: toSeries(cohort), groups: groupsObj, groupLabels, computedAt: now } }, - { upsert: true }); - return { students: students.length, cohortWeeks: toSeries(cohort).length, groups: groups.size }; -} - -// Student-facing payload: their own weekly line + the two cached reference lines. -export async function buildTrajectoryState(student) { - const joinMs = student.internshipStartDate ? new Date(student.internshipStartDate).getTime() : null; - const txns = joinMs - ? await SPTransaction.find({ email: student.email }).select('dateTime balanceAfter').sort({ dateTime: 1 }).lean() - : []; - const you = joinMs ? weeklySeries(txns, joinMs, Date.now()) : []; - const snap = await TrajectorySnapshot.findOne({ key: 'latest' }).lean(); - const gk = student.internshipStartDate ? leaderboardGroup(student.internshipStartDate) : null; - return { - weeks: snap?.weeks || WEEKS, - you, - cohort: snap?.cohort || [], - group: (gk && snap?.groups?.[gk]) || [], - groupLabel: gk ? groupLabel(gk) : null, - computedAt: snap?.computedAt || null - }; -} diff --git a/server/services/vibe.js b/server/services/vibe.js deleted file mode 100644 index a4e5572..0000000 --- a/server/services/vibe.js +++ /dev/null @@ -1,149 +0,0 @@ -// ViBe Commitment-SP module logic (16 July cohort onward). -// Config + eligibility + state builder + bet validation + demo settlement. -// Progress here comes from VibeProgress (dummy locally; ViBe API in production). -import Student from '../models/Student.js'; -import VibeProgress from '../models/VibeProgress.js'; -import Commitment from '../models/Commitment.js'; -import SPTransaction from '../models/SPTransaction.js'; - -export const ELIGIBILITY_CUTOFF = new Date('2026-07-16T00:00:00.000Z'); - -// Progressive ladder order: Onboarding -> AI -> MERN. -export const COURSES = [ - { key: 'onboarding', name: 'Onboarding', hours: 10, courseId: '6a14258a4fa5339bade5d732', versionId: '6a14258a4fa5339bade5d733' }, - { key: 'ai', name: 'Fundamentals of AI', hours: 6, courseId: '6a055c4c79eef782c2548388', versionId: '6a055c4c79eef782c2548389' }, - { key: 'mern', name: 'MERN Stack', hours: 10, courseId: '6a0ec8254658465536acb121', versionId: '6a0ec8254658465536acb122' } -]; - -export const CONFIG = { - stakeMin: 50, stakeMax: 200, - multipliers: [2, 3, 4], // 1x dropped: under stake-debit, a 1x hit only returns the stake (net 0) - penaltyFactor: 0.5, // miss loses 0.5 * stake * multiplier - maxBetDays: 3, // deadline window 1–3 days - floorHours: 1, // 1 hour of content/week is mandatory - floorSp: 10 // flat SP for hitting the weekly floor -}; - -export function isVibeEligible(student) { - return Boolean(student?.internshipStartDate) && - new Date(student.internshipStartDate) >= ELIGIBILITY_CUTOFF; -} -export const courseByKey = k => COURSES.find(c => c.key === k); -export const floorPctFor = course => Math.round(CONFIG.floorHours / course.hours * 100); - -function daysFromToday(deadline) { - const t = new Date(); t.setHours(0, 0, 0, 0); - const d = new Date(deadline); d.setHours(0, 0, 0, 0); - return Math.round((d - t) / 86400000); -} - -// Build the full student-facing ViBe state. -export async function buildVibeState(student) { - const email = student.email; - const rows = await VibeProgress.find({ email }).lean(); - const prog = {}; - rows.forEach(r => { prog[r.course] = { pct: r.pct, week: r.weekHours, prior: !!r.priorCompleted }; }); - - const ladder = COURSES.map(c => { - const p = prog[c.key] || { pct: 0, week: 0, prior: false }; - const pct = p.prior ? 100 : p.pct; - return { key: c.key, name: c.name, hours: c.hours, pct, prior: p.prior, cleared: p.prior || pct >= 100 }; - }); - const currentLadder = ladder.find(l => !l.cleared) || null; - const currentCourse = currentLadder ? courseByKey(currentLadder.key) : null; - - const bets = await Commitment.find({ email, type: 'vibe' }).sort({ createdAt: -1 }).lean(); - const active = bets.find(b => b.status === 'active') || null; - const history = bets.filter(b => b.status !== 'active'); - const reserved = active ? active.reserved : 0; - const available = Math.max(0, (student.totalSp || 0) - reserved); - - const current = currentLadder ? { - key: currentLadder.key, - name: currentLadder.name, - pct: currentLadder.pct, - hours: currentLadder.hours, - floorPct: floorPctFor(currentCourse), - remaining: 100 - currentLadder.pct, - weekHours: prog[currentLadder.key]?.week ?? 0 - } : null; - - return { - eligible: true, - name: student.name, - totalSp: student.totalSp || 0, - available, reserved, - ladder, current, - weeklyFloor: current - ? { requiredHours: CONFIG.floorHours, doneHours: current.weekHours, met: current.weekHours >= CONFIG.floorHours, sp: CONFIG.floorSp } - : null, - active, history, - config: CONFIG - }; -} - -// Validate a place/edit request against the locked rules. Returns { errs, win, loss, baselinePct }. -export function validateBet({ state, course, goalPct, stake, multiplier, deadline, ignoreActive = false }) { - const errs = []; - const c = courseByKey(course); - if (!c) errs.push('Unknown course.'); - if (!state.current || state.current.key !== course) errs.push('You can only bet on your current course.'); - if (!ignoreActive && state.active) errs.push('You already have an active bet.'); - if (!CONFIG.multipliers.includes(multiplier)) errs.push('Invalid multiplier.'); - if (!(stake >= CONFIG.stakeMin && stake <= CONFIG.stakeMax)) errs.push(`Stake must be ${CONFIG.stakeMin}–${CONFIG.stakeMax} SP.`); - - const floorPct = c ? floorPctFor(c) : 0; - const remaining = state.current ? state.current.remaining : 0; - if (goalPct <= floorPct) errs.push(`Goal must beat the weekly floor (${floorPct}%).`); - if (goalPct > remaining) errs.push(`Goal exceeds your remaining ${remaining}%.`); - - if (deadline !== undefined) { - const days = daysFromToday(deadline); - if (days < 1 || days > CONFIG.maxBetDays) errs.push(`Deadline must be 1–${CONFIG.maxBetDays} days out.`); - } - - const loss = CONFIG.penaltyFactor * stake * multiplier; - const win = stake * multiplier; - // The stake is debited up-front; a miss debits the penalty on top. So you must - // be able to cover BOTH (worst case = stake + loss). When editing, this bet's - // already-debited stake and reserved penalty are unwound first. - const avail = state.available + (ignoreActive && state.active ? state.active.reserved + state.active.stake : 0); - const need = stake + loss; - if (need > avail) { - errs.push(`You need ${need} SP to place this (stake ${stake} + up to ${loss} loss); you have ${avail}.`); - } - return { errs, win, loss, baselinePct: state.current ? state.current.pct : 0 }; -} - -// Apply an SP change AND write a matching SP-Bank transaction so the student sees -// it. Stamps the entry after the latest one so the running balance stays ordered -// (dummy seed dates can be future-ish). Returns the new balance. -export async function applySpDelta(email, delta, reason) { - const student = await Student.findOne({ email }); - const newTotal = (student.totalSp || 0) + delta; - student.totalSp = newTotal; - if (newTotal > (student.highestSpEver || 0)) student.highestSpEver = newTotal; - await student.save(); - const last = await SPTransaction.findOne({ email }).sort({ dateTime: -1 }).lean(); - const when = new Date(Math.max(Date.now(), (last?.dateTime ? new Date(last.dateTime).getTime() + 60000 : 0))); - await SPTransaction.create({ - email, studentId: student._id, category: 'manual', sessionLabel: '', - deltaMode: 'absolute', deltaValue: delta, appliedDelta: delta, balanceAfter: newTotal, reason, dateTime: when - }); - return newTotal; -} - -// DEMO ONLY: resolve a bet (there is no live settlement cron locally). The stake -// was already debited at placement; here we apply the win (credit) or the miss -// penalty (debit), advance progress, and mark the bet. -export async function settleBetDemo(bet, result) { - const course = courseByKey(bet.course); - const label = `+${bet.goalPct}% ${course ? course.name : bet.course} (stake ${bet.stake} @ ${bet.multiplier}×)`; - const delta = result === 'won' ? bet.potentialWin : -bet.potentialLoss; - await applySpDelta(bet.email, delta, `ViBe goal ${result === 'won' ? 'HIT' : 'MISS'}: ${label}`); - const newPct = result === 'won' - ? Math.min(100, bet.baselinePct + bet.goalPct) - : Math.min(100, bet.baselinePct + Math.floor(bet.goalPct * 0.6)); // fell short of goal - await VibeProgress.updateOne({ email: bet.email, course: bet.course }, { $set: { pct: newPct } }, { upsert: true }); - await Commitment.updateOne({ _id: bet._id }, { $set: { status: result, resultDelta: delta, settledAt: new Date() } }); -}