Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions client/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function Landing({ config, onStudent }) {
return (
<main className="page">
<section className="hero">
<CohortPulseTicker />
<div className="hero-copy">
<p className="eyebrow">Spurti Motivation Engine</p>
<h1>Spurti Points track participation energy.</h1>
Expand Down Expand Up @@ -392,6 +393,62 @@ function StudentPulse({ profile, badges, nextActions }) {
</section>
);
}
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 (
<div className={`cohort-pulse ${spike ? 'pulse-spike' : ''} pulse-${mood}`}>
<div className="pulse-ticker-row">
<span className="pulse-dot" />
<span className="pulse-label">Cohort Pulse</span>
<span className="pulse-score">{score}%</span>
<span className="pulse-mood">{moodLabel}</span>
</div>
<div className="pulse-meter">
<div className="pulse-meter-fill" style={{ width: `${score}%` }} />
</div>
<div className="pulse-substats">
<span>{pulse.signals.activeNow} active now</span>
<span>·</span>
<span>{pulse.signals.spEarnedToday} SP earned today</span>
<span>·</span>
<span>{pulse.signals.participatingToday}/{pulse.signals.cohortSize} participating</span>
</div>
</div>
);
}

function Sparkline({ points }) {
const values = points.map(p => p.value);
Expand Down
88 changes: 88 additions & 0 deletions client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
43 changes: 43 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down