From 0451b0a26c67b40fe3ce131b476ad9d6706837a1 Mon Sep 17 00:00:00 2001 From: Govind Khandelwal Date: Thu, 16 Jul 2026 10:53:34 +0530 Subject: [PATCH 1/2] feat: add live cohort pulse ticker dashboard --- client/src/main.jsx | 57 ++++++++++++++++++++++++++++ client/src/styles.css | 88 +++++++++++++++++++++++++++++++++++++++++++ server/server.js | 43 +++++++++++++++++++++ 3 files changed, 188 insertions(+) diff --git a/client/src/main.jsx b/client/src/main.jsx index cebf2cc..f5eb7f1 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -115,6 +115,7 @@ function Landing({ config, onStudent }) { return (
+

Spurti Motivation Engine

Spurti Points track participation energy.

@@ -392,6 +393,62 @@ function StudentPulse({ profile, badges, nextActions }) {
); } +function CohortPulseTicker() { + const [pulse, setPulse] = useState(null); + const [prevScore, setPrevScore] = useState(null); + const [spike, setSpike] = useState(false); + + useEffect(() => { + let active = true; + async function loadPulse() { + try { + const res = await fetch(`${API}/cohort-pulse`); + if (!res.ok) return; + const data = await res.json(); + if (!active) return; + setPulse(prev => { + if (prev && data.pulseScore > prev.pulseScore + 4) { + setSpike(true); + setTimeout(() => setSpike(false), 2500); + } + return data; + }); + } catch { + // silent — the ticker just won't update this cycle + } + } + loadPulse(); + const id = setInterval(loadPulse, 15000); + return () => { active = false; clearInterval(id); }; + }, []); + + if (!pulse) return null; + + const score = pulse.pulseScore; + const mood = score >= 70 ? 'high' : score >= 40 ? 'medium' : 'low'; + const moodLabel = score >= 70 ? 'Buzzing' : score >= 40 ? 'Active' : 'Quiet'; + + return ( +
+
+ + Cohort Pulse + {score}% + {moodLabel} +
+
+
+
+
+ {pulse.signals.activeNow} active now + · + {pulse.signals.spEarnedToday} SP earned today + · + {pulse.signals.participatingToday}/{pulse.signals.cohortSize} participating +
+
+ ); +} function Sparkline({ points }) { const values = points.map(p => p.value); diff --git a/client/src/styles.css b/client/src/styles.css index e135100..8d35438 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -514,3 +514,91 @@ 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; } + +.cohort-pulse { + max-width: 560px; + margin: 0 0 24px 0; + padding: 14px 18px; + border-radius: 12px; + border: 1px solid #333; + background: #151515; + transition: box-shadow 0.4s ease, border-color 0.4s ease; +} + +.pulse-ticker-row { + display: flex; + align-items: center; + gap: 10px; +} + +.pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4caf50; + animation: pulse-blink 1.6s ease-in-out infinite; +} + +.pulse-low .pulse-dot { background: #888; } +.pulse-medium .pulse-dot { background: #f0c766; } +.pulse-high .pulse-dot { background: #4caf50; } + +@keyframes pulse-blink { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.8); } +} + +.pulse-label { + font-size: 0.85em; + color: #999; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pulse-score { + font-size: 1.6em; + font-weight: 700; + color: #fff; + margin-left: auto; + transition: color 0.3s ease; +} + +.pulse-mood { + font-size: 0.85em; + padding: 2px 10px; + border-radius: 20px; + background: #2a2a2a; + color: #ccc; +} + +.pulse-meter { + width: 100%; + height: 6px; + background: #2a2a2a; + border-radius: 6px; + overflow: hidden; + margin: 10px 0 8px; +} + +.pulse-meter-fill { + height: 100%; + background: linear-gradient(90deg, #4caf50, #8bc34a); + transition: width 1s ease; +} + +.pulse-substats { + display: flex; + gap: 8px; + font-size: 0.8em; + color: #888; + flex-wrap: wrap; +} + +.pulse-spike { + border-color: #4caf50; + box-shadow: 0 0 20px rgba(76, 175, 80, 0.4); +} + +.pulse-spike .pulse-score { + color: #4caf50; +} diff --git a/server/server.js b/server/server.js index 11f6e4f..36595f4 100644 --- a/server/server.js +++ b/server/server.js @@ -321,6 +321,49 @@ api.get('/leaderboard', async (req, res) => { trophyLeague: leagueBand(s.totalSp) }))); }); +api.get('/cohort-pulse', async (_req, res) => { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const [activeStudents, todaysTransactions, todaysEvents] = await Promise.all([ + Student.find({ status: 'active' }).select('email').lean(), + SPTransaction.find({ dateTime: { $gte: todayStart } }).lean(), + SessionEvent.find({ timestamp: { $gte: todayStart } }).select('email').lean() + ]); + + const activeEmails = new Set(activeStudents.map(s => s.email)); + const cohortSize = activeEmails.size || 1; + + // Signal 1: live check-ins right now (last 60 seconds) + let activeNow = 0; + for (const [email, data] of liveViewers.entries()) { + if (activeEmails.has(email) && now.getTime() - data.lastSeen.getTime() <= 60_000) activeNow++; + } + const checkinSignal = Math.min(1, activeNow / Math.max(1, cohortSize * 0.1)); + + // Signal 2: total SP earned today, cohort-wide (positive credits only) + const spEarnedToday = todaysTransactions + .filter(tx => activeEmails.has(tx.email) && tx.appliedDelta > 0) + .reduce((sum, tx) => sum + tx.appliedDelta, 0); + const spSignal = Math.min(1, spEarnedToday / (cohortSize * 5)); + + // Signal 3: % of cohort with any activity today + const participatingEmails = new Set(todaysEvents.filter(e => activeEmails.has(e.email)).map(e => e.email)); + const participationSignal = Math.min(1, participatingEmails.size / cohortSize); + + const pulseScore = Math.round(((checkinSignal * 0.3) + (spSignal * 0.4) + (participationSignal * 0.3)) * 100); + + res.json({ + pulseScore, + generatedAt: now, + signals: { + activeNow, + cohortSize, + spEarnedToday, + participatingToday: participatingEmails.size + } + }); +}); api.post('/ping', async (req, res) => { const { email, name, page } = req.body || {}; From 9c40fec5b4e4b5dc3e1864462cc235d337996e2a Mon Sep 17 00:00:00 2001 From: Govind Khandelwal Date: Thu, 6 Aug 2026 23:04:12 +0530 Subject: [PATCH 2/2] ready for deployment --- client/src/ngrok.exe | 0 client/vite.config.js | 1 + 2 files changed, 1 insertion(+) create mode 100644 client/src/ngrok.exe diff --git a/client/src/ngrok.exe b/client/src/ngrok.exe new file mode 100644 index 0000000..e69de29 diff --git a/client/vite.config.js b/client/vite.config.js index b881374..e62c86e 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -5,6 +5,7 @@ export default defineConfig({ base: './', plugins: [react()], server: { + allowedHosts: true, proxy: { '/spurti': { target: 'http://localhost:5290',