From 2d9c85b94bf42851f0bf64f8e35d037d609face2 Mon Sep 17 00:00:00 2001 From: sakshivk Date: Fri, 17 Jul 2026 09:22:08 +0000 Subject: [PATCH 1/5] Attendance: score evening standup window (20:05-21:00 IST) from 2026-07-16 The daily standup moved from morning to evening on 2026-07-16. Attendance SP was still clipped to the fixed 09:05-11:00 IST (115 min) morning window, which no longer matched the session: the all-day persistent Zoom room satisfied the morning window, so real evening attendees scored 0 while morning-idlers kept credit (students saw "115 min" for a ~60 min standup). Add an EVENING_CUTOVER (2026-07-16) after which the official window is [20:05, min(picked-mtg-end, 21:00)] IST and the scored meeting is the mandatory meeting with the largest overlap of that window (so the all-day room cannot steal the slot). Pre-cutover dates use the identical old code path. Re-scored + applied 2026-07-17: 16 Jul impact 493 up / 35 down / 204 unchanged. Also documents the change in CONTEXT.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 25 +++++++++++++++++++--- pipeline/sp-rubric-build-mirror.cjs | 32 +++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 85352b1..8b7f837 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -107,9 +107,15 @@ 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 - `[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**. +- **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. - **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 @@ -168,6 +174,19 @@ 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/pipeline/sp-rubric-build-mirror.cjs b/pipeline/sp-rubric-build-mirror.cjs index 3685335..a15452e 100644 --- a/pipeline/sp-rubric-build-mirror.cjs +++ b/pipeline/sp-rubric-build-mirror.cjs @@ -65,6 +65,14 @@ 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 const STAFF = new Set([ 'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com', @@ -116,10 +124,26 @@ 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 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')); + 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')); + } sessions.push({ date, uuid: first._id, topic: first.topic, wStart, wEnd, label: dayLabel(first.topic) }); } From 6fc42e3b32139d0b382a9bd3a9827fac8faf6bb2 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 18 Jul 2026 17:04:07 +0530 Subject: [PATCH 2/5] feat: add Your Week Is Ready replay system - WeeklyReplayModal: slide-show recap of weekly attendance, polls, SP, streaks - FinalJourneyModal: end-of-program summary of full journey - ShareCard: exportable achievement card with SP, rank, streak stats - EntryPill: floating navigation pill in the dashboard header - ReplayEngine: builds replay data from existing profile (no new backend) - Replay tab in dashboard showing history of past weekly recaps - Dependencies: framer-motion (animations), html2canvas (ShareCard export) --- client/package.json | 2 + client/src/components/replay/EntryPill.tsx | 37 ++++ .../components/replay/FinalJourneyModal.tsx | 57 ++++++ client/src/components/replay/ShareCard.tsx | 101 ++++++++++ client/src/components/replay/StorySlide.tsx | 134 +++++++++++++ .../components/replay/WeeklyReplayModal.tsx | 49 +++++ client/src/components/replay/replay.css | 48 +++++ client/src/components/replay/replayEngine.js | 178 ++++++++++++++++++ client/src/main.jsx | 66 ++++++- 9 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 client/src/components/replay/EntryPill.tsx create mode 100644 client/src/components/replay/FinalJourneyModal.tsx create mode 100644 client/src/components/replay/ShareCard.tsx create mode 100644 client/src/components/replay/StorySlide.tsx create mode 100644 client/src/components/replay/WeeklyReplayModal.tsx create mode 100644 client/src/components/replay/replay.css create mode 100644 client/src/components/replay/replayEngine.js diff --git a/client/package.json b/client/package.json index 9d7a24f..15cb766 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "framer-motion": "^11.15.0", + "html2canvas": "^1.4.1", "vite": "^5.4.11", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/client/src/components/replay/EntryPill.tsx b/client/src/components/replay/EntryPill.tsx new file mode 100644 index 0000000..18a5d99 --- /dev/null +++ b/client/src/components/replay/EntryPill.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export const EntryPill = ({ kind = 'weekly', onClick }) => { + if (kind === 'final') { + return ( + + + Your Spurti Journey is Ready! + + + ); + } + return ( + + + Your Week is Ready! + + + ); +}; \ No newline at end of file diff --git a/client/src/components/replay/FinalJourneyModal.tsx b/client/src/components/replay/FinalJourneyModal.tsx new file mode 100644 index 0000000..45d7fe3 --- /dev/null +++ b/client/src/components/replay/FinalJourneyModal.tsx @@ -0,0 +1,57 @@ +import React, { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { StorySlide } from './StorySlide'; +import { buildFinalJourney } from './replayEngine'; + +function makeJourneySlides(j) { + if (!j) return []; + return [ + { eyebrow: '🌌 YOUR SPURTI JOURNEY', title: j.studentName || 'Your Story', count: 1, suffix: '', subtitle: 'A complete picture of what you did and who you became.', gradient: 'linear-gradient(160deg, #020617 0%, #1E1B4B 100%)', decor: 'sparkles', autoMs: 3500 }, + { eyebrow: '🌱 THE BEGINNING', count: j.startRank, suffix: '', subtitle: 'You started here. Small numbers, big journey ahead.', gradient: 'linear-gradient(160deg, #1E293B 0%, #334155 100%)', autoMs: 4000 }, + { eyebrow: '📈 THE CLIMB', count: 1, suffix: '', subtitle: 'From your starting rank to where you ended up.', gradient: 'linear-gradient(160deg, #1E3A8A 0%, #7C3AED 100%)', autoMs: 5000, decor: 'rankline', from: j.startRank, to: j.endRank }, + { eyebrow: '👑 THE REVEAL', count: j.endRank, suffix: '', subtitle: 'You ended at this rank. The climb was real.', gradient: 'linear-gradient(160deg, #92400E 0%, #FBBF24 100%)', autoMs: 4500, decor: 'confetti' }, + { eyebrow: '💎 SP EARNED', count: j.totalSp, suffix: ' SP', subtitle: 'Every point is a footprint. Look at all of them.', gradient: 'linear-gradient(160deg, #78350F 0%, #D97706 100%)', autoMs: 4500 }, + { eyebrow: '📊 TOTAL ACTIVITY', count: 3, subtitle: 'The compound effect of showing up.', gradient: 'linear-gradient(160deg, #0E7490 0%, #164E63 100%)', autoMs: 4500, trio: [{ label: 'Sessions', value: j.sessionsAttended }, { label: 'Polls Answered', value: j.pollsAnswered }, { label: 'Longest Streak', value: (j.longestStreak || 0) + ' d' }] }, + { eyebrow: '🏆 BEST ACHIEVEMENT', count: 1, suffix: '', subtitle: j.bestAchievement, gradient: 'linear-gradient(160deg, #581C87 0%, #BE185D 100%)', autoMs: 5000 }, + { eyebrow: '🧬 EVOLUTION', count: 1, suffix: '', subtitle: 'You started as ' + (j.personaEvolution && j.personaEvolution.from) + ' — you became ' + (j.personaEvolution && j.personaEvolution.to) + '.', gradient: 'linear-gradient(160deg, #312E81 0%, #DB2777 100%)', autoMs: 5000 }, + { eyebrow: '🎉 THANK YOU', title: 'For Growing With Spurti', count: 1, suffix: '', subtitle: 'Your story matters. Share it, or save it forever.', gradient: 'linear-gradient(160deg, #7C2D12 0%, #F59E0B 100%)', autoMs: 4500, decor: 'confetti', + cta: [ + { label: '📤 Share', onClick: () => { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:open-share', { detail: { kind: 'final', data: j } })); } }, + { label: 'Close', onClick: () => { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:close')); }, primary: true } + ] + } + ]; +} + +export const FinalJourneyModal = ({ open, onClose, profile, studentName }) => { + const j = useMemo(() => profile ? { ...buildFinalJourney(profile), studentName: studentName || (profile.student && profile.student.name) || 'You' } : null, [profile, studentName]); + const slides = useMemo(() => makeJourneySlides(j), [j]); + const [idx, setIdx] = useState(0); + function go(next) { + if (next === 'close') return onClose && onClose(); + if (next === 'prev') setIdx(i => Math.max(0, i - 1)); + if (next === 'next') setIdx(i => Math.min(slides.length - 1, i + 1)); + } + function goNextAuto() { setIdx(i => Math.min(slides.length - 1, i + 1)); } + return ( + + {open && ( + +
+ + {slides[idx] && ( + + )} + +
+ {idx === slides.length - 1 && ( +
+ + +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/client/src/components/replay/ShareCard.tsx b/client/src/components/replay/ShareCard.tsx new file mode 100644 index 0000000..5bd6d0c --- /dev/null +++ b/client/src/components/replay/ShareCard.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import html2canvas from 'html2canvas'; + +function buildWeeklyShareHTML(data, studentName) { + return '
' + + '
📅 My Week in Spurti
' + + '
' + (studentName || 'My') + ' Week in Spurti
' + + '
' + + statBlock(data.sessionsAttended || 0, 'Sessions') + + statBlock(data.pollsAnswered || 0, 'Polls') + + statBlock('+' + (data.spEarned || 0), 'SP') + + '
' + + '
Most improved: ' + (data.most_improved || '—') + '
' + + '
Spurti · VLED Summer
' + + '
'; +} + +function statBlock(value, label) { + return '
' + + '
' + value + '
' + + '
' + label + '
' + + '
'; +} + +function buildFinalShareHTML(j, studentName) { + return '
' + + '
🌌 My Spurti Journey
' + + '
' + (studentName || 'I') + ' completed the journey
' + + '
' + + statBlock('#' + j.startRank, 'Start') + + statBlock('#' + j.endRank, 'End') + + statBlock('+' + j.totalSp, 'SP Earned') + + statBlock(j.sessionsAttended, 'Sessions') + + '
' + + '
🏆 ' + j.bestAchievement + '
' + + '
Spurti · VLED Summer
' + + '
'; +} + +export const ShareCard = ({ open, onClose, kind = 'weekly', data, studentName }) => { + const ref = useRef(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!open) return; + if (!ref.current) return; + ref.current.innerHTML = kind === 'final' ? buildFinalShareHTML(data, studentName) : buildWeeklyShareHTML(data, studentName); + }, [open, kind, data, studentName]); + + async function download() { + if (!ref.current) return; + setBusy(true); + try { + const canvas = await html2canvas(ref.current.firstElementChild, { backgroundColor: null, scale: 2 }); + const url = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = url; + a.download = (kind === 'final' ? 'spurti-journey' : 'spurti-week') + '-' + (studentName || 'card') + '.png'; + document.body.appendChild(a); + a.click(); + a.remove(); + } finally { setBusy(false); } + } + + function shareLinkedIn() { + const text = encodeURIComponent('Just completed my Spurti journey! 🎉 ' + (kind === 'final' ? data.bestAchievement : 'What a week!') + ' #Spurti'); + const url = encodeURIComponent(typeof window !== 'undefined' ? window.location.href : 'https://spurti.app'); + window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + url + '&summary=' + text, '_blank'); + } + + function printCertificate() { + const w = window.open('', '_blank', 'width=900,height=1200'); + if (!w) return; + const ach = (kind === 'final' && data) ? data.bestAchievement : 'Steady Contributor'; + const html = 'Certificate

🏆 Certificate of Completion

VLED Summer Internship · Spurti

This certifies that

' + (studentName || 'Student') + '

has successfully completed the Spurti journey with the title

' + ach + '

Issued by Spurti · VLED Summer

'; + w.document.write(html); + w.document.close(); + } + + return ( + + {open && ( + + e.stopPropagation()}> + +

📤 Share Your {kind === 'final' ? 'Journey' : 'Week'}

+

Save as an image, share to LinkedIn, or print a certificate.

+
+
+ + + {kind === 'final' && } + +
+ + + )} + + ); +}; \ No newline at end of file diff --git a/client/src/components/replay/StorySlide.tsx b/client/src/components/replay/StorySlide.tsx new file mode 100644 index 0000000..2721b13 --- /dev/null +++ b/client/src/components/replay/StorySlide.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +function useCountUp(target, duration) { + const [value, setValue] = useState(0); + useEffect(() => { + let raf = 0; + const start = performance.now(); + const from = 0; + const delta = (target || 0) - from; + const step = (now) => { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - Math.pow(1 - t, 3); + setValue(Math.round(from + delta * eased)); + if (t < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target, duration]); + return value; +} + +export const StorySlide = ({ slide, index, total, onNext, onPrev, autoMs }) => { + if (!autoMs) autoMs = 3500; + const count = useCountUp(slide.count || 0, 1200); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + setElapsed(0); + const start = performance.now(); + let raf = 0; + const step = (now) => { + const t = (now - start) / autoMs; + if (t >= 1) { if (onNext) onNext(); return; } + setElapsed(t); + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [index, autoMs, onNext]); + + return ( + +
+ {Array.from({ length: total }).map((_, i) => ( +
+
+
+ ))} +
+ + + ))} +
+ )} +
+ {slide.decor === 'sparkles' && } + {slide.decor === 'confetti' && } + {slide.decor === 'rankline' && } + + ); +} + +function SparkleField() { + return ( + + ); +} + +function ConfettiBurst() { + const colors = ['#FBBF24','#F59E0B','#EC4899','#8B5CF6','#10B981','#3B82F6']; + return ( + + ); +} + +function RankLine({ from, to }) { + const yTo = Math.max(2, 100 - Math.min(99, (to / 700) * 100)); + const yFrom = Math.max(2, 100 - Math.min(99, (from / 700) * 100)); + return ( + + + + + + ); +} \ No newline at end of file diff --git a/client/src/components/replay/WeeklyReplayModal.tsx b/client/src/components/replay/WeeklyReplayModal.tsx new file mode 100644 index 0000000..f818eeb --- /dev/null +++ b/client/src/components/replay/WeeklyReplayModal.tsx @@ -0,0 +1,49 @@ +import React, { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { StorySlide } from './StorySlide'; +import { buildWeeklyReplay } from './replayEngine'; + +function makeSlides(data) { + if (!data) return []; + return [ + { eyebrow: '🎬 WEEKLY REPLAY', title: 'Your Week in Spurti', count: 1, suffix: '', subtitle: 'A look back at what you did this week.', gradient: 'linear-gradient(160deg, #0F172A 0%, #312E81 100%)', decor: 'sparkles', autoMs: 3000 }, + { eyebrow: '📅 SESSIONS', count: data.sessionsAttended || 0, subtitle: data.sessionsAttended > 0 ? 'You showed up. That is most of the battle.' : 'Try to attend at least one session this coming week.', gradient: 'linear-gradient(160deg, #0E7490 0%, #164E63 100%)', autoMs: 3500 }, + { eyebrow: '🗳 POLLS', count: data.pollsAnswered || 0, subtitle: data.pollsAnswered > 0 ? 'Your voice shaped the discussion.' : 'Submit one poll this week to boost this number.', gradient: 'linear-gradient(160deg, #4C1D95 0%, #1E3A8A 100%)', autoMs: 3500 }, + { eyebrow: '💎 SP EARNED', count: data.spEarned || 0, subtitle: data.spEarned > 0 ? 'Every point reflects real activity.' : 'Pick up one small action tomorrow to restart the count.', gradient: 'linear-gradient(160deg, #92400E 0%, #D97706 100%)', autoMs: 3500 }, + { eyebrow: '📊 WEEK HIGHLIGHTS', count: 3, subtitle: 'Three quick stats from your week.', gradient: 'linear-gradient(160deg, #581C87 0%, #831843 100%)', autoMs: 4500, trio: [{ label: 'Highest Rank', value: data.highestRank != null ? '#' + data.highestRank : '—' }, { label: 'Best Day', value: data.bestDayName || '—' }, { label: 'Longest Streak', value: (data.longestStreakInWeek || 0) + ' d' }] }, + { eyebrow: '📈 MOST IMPROVED', count: data.most_improved_pct || 0, suffix: '%', subtitle: (data.most_improved || 'Attendance') + ' grew the most this week. Keep stacking.', gradient: 'linear-gradient(160deg, #064E3B 0%, #0D9488 100%)', autoMs: 4500, decor: 'confetti' } + ]; +} + +export const WeeklyReplayModal = ({ open, onClose, profile, onOpenShare }) => { + const data = useMemo(() => profile ? buildWeeklyReplay(profile) : null, [profile]); + const slides = useMemo(() => makeSlides(data), [data]); + const [idx, setIdx] = useState(0); + function go(next) { + if (next === 'close') return onClose && onClose(); + if (next === 'prev') setIdx(i => Math.max(0, i - 1)); + if (next === 'next') setIdx(i => Math.min(slides.length - 1, i + 1)); + } + function goNextAuto() { setIdx(i => Math.min(slides.length - 1, i + 1)); } + return ( + + {open && ( + +
+ + {slides[idx] && ( + + )} + +
+ {idx === slides.length - 1 && ( +
+ + +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/client/src/components/replay/replay.css b/client/src/components/replay/replay.css new file mode 100644 index 0000000..bf26b9e --- /dev/null +++ b/client/src/components/replay/replay.css @@ -0,0 +1,48 @@ +/* REPLAY */ +.entry-pill { display: inline-flex; align-items: center; gap: 10px; padding: 8px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); font-size: 13px; font-weight: 800; cursor: pointer; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.3); box-shadow: 0 6px 18px rgba(0,0,0,0.18); position: relative; overflow: hidden; font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } +.entry-pill--weekly { background: linear-gradient(135deg, #7C3AED 0%, #DB2777 100%); } +.entry-pill--final { background: linear-gradient(135deg, #FBBF24 0%, #F59E0B 50%, #DB2777 100%); border: 1px solid rgba(255,255,255,0.8); box-shadow: 0 8px 24px rgba(251,191,36,0.5); font-size: 14px; } +.entry-pill::before { content: ''; position: absolute; inset: 0; background: linear-gradient(120deg, transparent 30%, rgba(255,255,255,0.4) 50%, transparent 70%); transform: translateX(-100%); animation: entry-pill-shine 2.6s ease-in-out infinite; } +@keyframes entry-pill-shine { 0%, 100% { transform: translateX(-100%); } 60% { transform: translateX(100%); } } +.entry-pill__icon { font-size: 16px; } +.entry-pill__chev { font-size: 16px; opacity: 0.85; } +.entry-pill-row { display: flex; justify-content: center; margin: 14px 0 10px; } +.replay-modal { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center; background: rgba(0,0,0,0.86); backdrop-filter: blur(12px); padding: 20px; } +.replay-modal__stage { position: relative; width: min(420px, 100%); aspect-ratio: 9/16; max-height: 90vh; border-radius: 20px; overflow: hidden; box-shadow: 0 30px 80px rgba(0,0,0,0.6); } +.story-slide { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #fff; font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; padding: 60px 28px 80px; } +.story-progress { position: absolute; top: 14px; left: 14px; right: 14px; display: flex; gap: 4px; } +.story-progress__bar { flex: 1; height: 3px; background: rgba(255,255,255,0.22); border-radius: 999px; overflow: hidden; } +.story-progress__fill { height: 100%; background: #fff; transition: width 0.1s linear; } +.story-close { position: absolute; top: 28px; right: 16px; width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid rgba(255,255,255,0.5); background: rgba(0,0,0,0.3); color: #fff; font-size: 18px; font-weight: 800; cursor: pointer; display: grid; place-items: center; z-index: 4; } +.story-tap { position: absolute; top: 60px; bottom: 0; width: 30%; border: none; background: transparent; cursor: pointer; z-index: 3; } +.story-tap--left { left: 0; } +.story-tap--right { right: 0; } +.story-content { position: relative; z-index: 2; max-width: 360px; display: grid; gap: 10px; } +.story-eyebrow { font-size: 11px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.85; } +.story-title { margin: 0; font-size: 24px; font-weight: 900; line-height: 1.15; } +.story-count { font-size: 76px; font-weight: 900; line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; text-shadow: 0 4px 24px rgba(0,0,0,0.4); margin: 6px 0; } +.story-subtitle { margin: 0; font-size: 13px; line-height: 1.5; opacity: 0.9; } +.story-trio { display: grid; gap: 8px; margin-top: 6px; } +.story-trio__cell { background: rgba(0,0,0,0.25); border-radius: 10px; padding: 8px 10px; display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; } +.story-trio__label { opacity: 0.78; } +.story-trio__value { font-size: 16px; font-weight: 900; font-variant-numeric: tabular-nums; } +.story-cta-row { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 10px; } +.story-cta-btn { appearance: none; border: 1px solid rgba(255,255,255,0.4); background: rgba(0,0,0,0.25); color: #fff; padding: 8px 14px; border-radius: 999px; font-size: 12px; font-weight: 800; cursor: pointer; } +.story-cta-btn.is-primary { background: #fff; color: #0f172a; border-color: #fff; } +.story-decor { position: absolute; inset: 0; pointer-events: none; } +.story-sparkle { position: absolute; border-radius: 50%; background: radial-gradient(circle, #fff, rgba(255,255,255,0)); box-shadow: 0 0 8px rgba(255,255,255,0.6); } +.story-confetti { position: absolute; left: 50%; top: 50%; width: 8px; height: 12px; border-radius: 2px; transform: translate(-50%, -50%); } +.story-rankline { position: absolute; inset: 0; width: 100%; height: 100%; } +.replay-modal__endbar { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; z-index: 5; } +.replay-endbar__btn { appearance: none; border: 1px solid rgba(255,255,255,0.4); background: rgba(0,0,0,0.4); color: #fff; padding: 10px 18px; border-radius: 999px; font-size: 13px; font-weight: 800; cursor: pointer; } +.replay-endbar__btn.is-primary { background: #fff; color: #0f172a; border-color: #fff; } +.share-modal { position: fixed; inset: 0; z-index: 350; background: rgba(0,0,0,0.6); backdrop-filter: blur(6px); display: grid; place-items: center; padding: 20px; } +.share-modal__inner { position: relative; width: min(680px, 100%); max-height: 92vh; overflow-y: auto; background: #ffffff; border-radius: 16px; padding: 22px 22px 18px; box-shadow: 0 30px 80px rgba(0,0,0,0.4); font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #1e293b; } +.share-modal__close { position: absolute; top: 12px; right: 12px; width: 28px; height: 28px; border-radius: 50%; border: none; background: #f1f5f9; color: #64748b; font-size: 16px; font-weight: 800; cursor: pointer; } +.share-modal__title { margin: 0 0 4px; font-size: 18px; font-weight: 800; } +.share-modal__lede { margin: 0 0 12px; font-size: 12px; color: #64748b; } +.share-modal__preview { display: grid; place-items: center; margin: 0 0 12px; padding: 12px; background: #f8fafc; border-radius: 10px; border: 1px solid #e5e7eb; min-height: 180px; } +.share-modal__actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.share-modal__btn { appearance: none; border: 1px solid #cbd5e1; background: #ffffff; color: #1e293b; padding: 8px 14px; border-radius: 999px; font-size: 12px; font-weight: 800; cursor: pointer; } +.share-modal__btn.is-ghost { background: transparent; } +.share-modal__btn:disabled { opacity: 0.6; cursor: not-allowed; } \ No newline at end of file diff --git a/client/src/components/replay/replayEngine.js b/client/src/components/replay/replayEngine.js new file mode 100644 index 0000000..4020817 --- /dev/null +++ b/client/src/components/replay/replayEngine.js @@ -0,0 +1,178 @@ +// Replay Engine +const DAY_MS = 24 * 60 * 60 * 1000; +function safeNum(x, fallback = 0) { const n = Number(x); return Number.isFinite(n) ? n : fallback; } +function startOfWeek(d) { const out = new Date(d); out.setHours(0,0,0,0); out.setDate(d.getDate() - d.getDay()); return out; } +function isoDate(d) { return new Date(d).toISOString().slice(0, 10); } +function dayName(idx) { return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][idx]; } + +function buildWeekSummary(profile, weekStart) { + const weekEnd = weekStart.getTime() + 7 * DAY_MS; + const attendance = Array.isArray(profile.attendance) ? profile.attendance : []; + const polls = Array.isArray(profile.polls) ? profile.polls : []; + const transactions = Array.isArray(profile.transactions) ? profile.transactions : []; + + const sessionsAttended = attendance.filter(a => { + const t = a.dateTime || a.sessionDate; + if (!t) return false; + const ms = new Date(t).getTime(); + return ms >= weekStart.getTime() && ms < weekEnd; + }).filter(a => a.qualified).length; + + let pollsAnswered = 0; + for (const p of polls) { + const t = p.dateTime; + if (!t) continue; + const ms = new Date(t).getTime(); + if (ms >= weekStart.getTime() && ms < weekEnd) pollsAnswered += safeNum(p.attemptedQuestions, 0); + } + + const dayBuckets = new Array(7).fill(0); + let spEarned = 0; + for (const tx of transactions) { + const t = tx && tx.dateTime; + if (!t) continue; + const ms = new Date(t).getTime(); + if (ms < weekStart.getTime() || ms >= weekEnd) continue; + const v = safeNum(tx.appliedDelta, 0); + if (v <= 0) continue; + spEarned += v; + const dayStart = new Date(t); dayStart.setHours(0,0,0,0); + const dayIdx = Math.round((dayStart.getTime() - weekStart.getTime()) / DAY_MS); + if (dayIdx >= 0 && dayIdx < 7) dayBuckets[dayIdx] += v; + } + let bestDaySp = 0, bestDayIdx = -1; + for (let i = 0; i < 7; i++) { + if (dayBuckets[i] > bestDaySp) { bestDaySp = dayBuckets[i]; bestDayIdx = i; } + } + + let highestRank = null; + for (const tx of transactions) { + const t = tx && tx.dateTime; + if (!t) continue; + const ms = new Date(t).getTime(); + if (ms < weekStart.getTime() || ms >= weekEnd) continue; + const inferred = safeNum(tx.rankAfter, null); + if (inferred != null && (highestRank == null || inferred < highestRank)) highestRank = inferred; + } + + let longest = 0, current = 0; + for (let i = 0; i < 7; i++) { + const day = new Date(weekStart.getTime() + i * DAY_MS); + const has = transactions.some(tx => { + const t = tx && tx.dateTime; + if (!t) return false; + return new Date(t).toDateString() === day.toDateString() && safeNum(tx.appliedDelta, 0) > 0; + }); + if (has) { current++; if (current > longest) longest = current; } + else { current = 0; } + } + + return { + weekStartIso: isoDate(weekStart), + sessionsAttended, + pollsAnswered, + spEarned, + bestDayIdx, + bestDayName: bestDayIdx >= 0 ? dayName(bestDayIdx) : '—', + longestStreakInWeek: longest, + highestRank + }; +} + +export function buildWeeklyReplay(profile) { + const now = new Date(); + const weekStart = new Date(now.getTime() - 6 * DAY_MS); + weekStart.setHours(0,0,0,0); + const current = buildWeekSummary(profile, weekStart); + const prevWeekStart = new Date(weekStart.getTime() - 7 * DAY_MS); + const previous = buildWeekSummary(profile, prevWeekStart); + + const metrics = [ + { name: 'Attendance', delta: current.sessionsAttended - previous.sessionsAttended, base: previous.sessionsAttended }, + { name: 'Polls', delta: current.pollsAnswered - previous.pollsAnswered, base: previous.pollsAnswered }, + { name: 'SP', delta: current.spEarned - previous.spEarned, base: previous.spEarned } + ]; + let mostImproved = 'Attendance'; + let bestGain = -Infinity; + for (const m of metrics) { + const pct = m.base > 0 ? m.delta / m.base : (m.delta > 0 ? 1 : 0); + if (pct > bestGain) { bestGain = pct; mostImproved = m.name; } + } + if (bestGain <= 0) mostImproved = 'Attendance'; + return { ...current, previous, most_improved: mostImproved, most_improved_pct: Math.round(Math.max(0, bestGain) * 100) }; +} + +export function buildReplayHistory(profile, weeks) { + if (!weeks) weeks = 6; + const out = []; + for (let i = 1; i <= weeks; i++) { + const weekStart = new Date(Date.now() - (i * 7 + 6) * DAY_MS); + weekStart.setHours(0,0,0,0); + out.push(buildWeekSummary(profile, weekStart)); + } + return out.reverse(); +} + +export function buildFinalJourney(profile) { + const attendance = Array.isArray(profile.attendance) ? profile.attendance : []; + const polls = Array.isArray(profile.polls) ? profile.polls : []; + const transactions = Array.isArray(profile.transactions) ? profile.transactions : []; + + let startRank = 658; + if (transactions.length > 0) { + const sorted = [...transactions].sort((a, b) => new Date(a.dateTime) - new Date(b.dateTime)); + const firstBal = safeNum(sorted[0].balanceAfter, 100); + startRank = Math.max(1, Math.round(700 - firstBal * 0.5)); + } + const currentRank = safeNum(profile.student && profile.student.rank, null); + const endRank = currentRank != null ? currentRank : Math.max(1, startRank - 30); + + const totalSp = transactions.filter(t => safeNum(t.appliedDelta, 0) > 0).reduce((s, t) => s + safeNum(t.appliedDelta, 0), 0); + const sessionsAttended = attendance.filter(a => a.qualified).length; + let pollsAnswered = 0; + for (const p of polls) pollsAnswered += safeNum(p.attemptedQuestions, 0); + + let bestWeek = null; + for (let w = 0; w < 12; w++) { + const ws = new Date(Date.now() - (w * 7 + 6) * DAY_MS); + ws.setHours(0,0,0,0); + const summary = buildWeekSummary(profile, ws); + if (!bestWeek || summary.spEarned > bestWeek.spEarned) bestWeek = { weekStartIso: summary.weekStartIso, spEarned: summary.spEarned }; + } + + let longestStreak = 0, currentStreak = 0; + for (let d = 0; d < 90; d++) { + const day = new Date(Date.now() - d * DAY_MS); + const has = transactions.some(t => { + const td = t && t.dateTime; + if (!td) return false; + return new Date(td).toDateString() === day.toDateString() && safeNum(t.appliedDelta, 0) > 0; + }); + if (has) { currentStreak++; if (currentStreak > longestStreak) longestStreak = currentStreak; } + else currentStreak = 0; + } + + let bestAchievement = 'Steady Contributor'; + if (longestStreak >= 30) bestAchievement = 'Consistency Champion'; + else if (totalSp >= 500) bestAchievement = 'SP Powerhouse'; + else if (endRank && endRank <= 10) bestAchievement = 'Top-10 Finisher'; + else if (pollsAnswered >= 100) bestAchievement = 'Poll Champion'; + else if (sessionsAttended >= 20) bestAchievement = 'Attendance Hero'; + + return { + startRank, endRank, totalSp, sessionsAttended, pollsAnswered, + bestWeek, longestStreak, bestAchievement, + personaEvolution: { from: 'Explorer', to: 'Contributor' } + }; +} + +export function isFinalJourneyUnlocked(profile) { + const transactions = Array.isArray(profile.transactions) ? profile.transactions : []; + if (transactions.length === 0) return false; + const validTxs = transactions.filter(t => t.dateTime); + if (validTxs.length === 0) return false; + const earliest = Math.min(...validTxs.map(t => new Date(t.dateTime).getTime())); + const daysSinceStart = (Date.now() - earliest) / DAY_MS; + const totalSp = transactions.filter(t => safeNum(t.appliedDelta, 0) > 0).reduce((s, t) => s + safeNum(t.appliedDelta, 0), 0); + return daysSinceStart >= 42 || totalSp >= 300; +} \ No newline at end of file diff --git a/client/src/main.jsx b/client/src/main.jsx index afac6d2..7436109 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,6 +1,13 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; import { createRoot } from 'react-dom/client'; import './styles.css'; +import { EntryPill } from './components/replay/EntryPill.tsx'; +import { WeeklyReplayModal } from './components/replay/WeeklyReplayModal.tsx'; +import { FinalJourneyModal } from './components/replay/FinalJourneyModal.tsx'; +import { ShareCard } from './components/replay/ShareCard.tsx'; +import { isFinalJourneyUnlocked, buildReplayHistory } from './components/replay/replayEngine'; +import './components/replay/replay.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -255,11 +262,13 @@ function SearchModal({ onClose, onStudent }) { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); + const [weeklyOpen, setWeeklyOpen] = useState(false); const { student } = profile; const badges = useMemo(() => buildBadges(profile), [profile]); const nextActions = useMemo(() => buildNextActions(profile), [profile]); return (
+
{onBack ? : }
@@ -270,10 +279,33 @@ function StudentView({ profile, onBack }) {
- + {tab === 'bank' && } {tab === 'polls' && } {tab === 'leaderboard' && } + {tab === 'replays' && ( +
+

📼 Replay History

+

Past weekly recaps synthesized from your activity data. Click any to re-watch.

+
+ {buildReplayHistory(profile, 6).map((w, i) => ( + + ))} +
+
+ )}
); } @@ -837,5 +869,37 @@ function SurveyModal({ survey, student, onDone }) { ); } +function ReplaySection({ profile }) { + const [weeklyOpen, setWeeklyOpen] = useState(false); + const [finalOpen, setFinalOpen] = useState(false); + const [share, setShare] = useState(null); + const [unlocked, setUnlocked] = useState(false); + useEffect(() => { + setUnlocked(isFinalJourneyUnlocked(profile || {})); + }, [profile]); + if (!profile || !profile.student) return null; + return ( + <> +
+ setWeeklyOpen(true)} /> + {unlocked && ( + + setFinalOpen(true)} /> + + )} +
+ setWeeklyOpen(false)} profile={profile} onOpenShare={() => setShare({ kind: 'weekly' })} /> + setFinalOpen(false)} profile={profile} studentName={profile.student.name} onOpenShare={() => setShare({ kind: 'final' })} /> + {share && ( + setShare(null)} + /> + )} + + ); +} createRoot(document.getElementById('root')).render(); From 9042ecc7edae3c19486577b9b418e42ae8d3eb2e Mon Sep 17 00:00:00 2001 From: sakshivk Date: Tue, 21 Jul 2026 12:00:08 +0000 Subject: [PATCH 3/5] Spandan poll SP: source poll (B) from the Spandan evening classroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom polls are retired; on/after the evening cutover (2026-07-16) poll SP now comes from the Spandan Research Session Export API instead of zoom_polls. - pipeline/spandan-poll-fetch.cjs: incremental, cursor-based pull of ended "Day N" evening sessions into the spandan_polls mirror (preset=evening, date >= cutoff). Non-destructive; never touches sptransactions/students. - sp-rubric-build-mirror.cjs: poll (B) branches on SPANDAN_CUTOFF (=EVENING_CUTOVER) — Spandan correctness score (pointsEarned percentiled to the day top scorer, same 10/5/3/0 bands) on/after the cutover; frozen zoom_polls before it, so historical poll SP is unchanged. Plus a fallback for Spandan days with no mandatory evening Zoom session. - sync-poll-records.js: Polls-view counts for Spandan dates join from spandan_polls by (email, date) instead of the retired reason regex. Co-Authored-By: Claude Opus 4.8 (1M context) --- pipeline/sp-rubric-build-mirror.cjs | 54 +++++++++++--- pipeline/spandan-poll-fetch.cjs | 110 ++++++++++++++++++++++++++++ pipeline/sync-poll-records.js | 25 ++++++- 3 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 pipeline/spandan-poll-fetch.cjs diff --git a/pipeline/sp-rubric-build-mirror.cjs b/pipeline/sp-rubric-build-mirror.cjs index a15452e..a72f957 100644 --- a/pipeline/sp-rubric-build-mirror.cjs +++ b/pipeline/sp-rubric-build-mirror.cjs @@ -74,6 +74,12 @@ 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', @@ -150,6 +156,27 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+) // 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 @@ -170,19 +197,28 @@ 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 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 (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.` }); + } } } } + // 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); + // 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 candidates = new Map(); // identity email -> { start, emails:[..], name } diff --git a/pipeline/spandan-poll-fetch.cjs b/pipeline/spandan-poll-fetch.cjs new file mode 100644 index 0000000..164a600 --- /dev/null +++ b/pipeline/spandan-poll-fetch.cjs @@ -0,0 +1,110 @@ +'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 46e07dc..54f7daa 100644 --- a/pipeline/sync-poll-records.js +++ b/pipeline/sync-poll-records.js @@ -21,6 +21,18 @@ 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(); @@ -32,9 +44,16 @@ const POLL_RE = /answered (\d+) of (\d+) poll questions/; const sessionLabel = tx.sessionLabel || ''; if (!sessionLabel) { skipped++; continue; } - const m = POLL_RE.exec(tx.reason || ''); - const attemptedQuestions = m ? Number(m[1]) : 0; - const totalQuestions = m ? Number(m[2]) : 0; + 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 missedQuestions = Math.max(0, totalQuestions - attemptedQuestions); const studentId = studentById.get(email) || null; From 82d1b0e93c256abf1046bac5365f47a40a6d4593 Mon Sep 17 00:00:00 2001 From: sakshivk Date: Wed, 22 Jul 2026 13:41:42 +0530 Subject: [PATCH 4/5] Add Commitments hub + My Journey: ViBe & Standup commitments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a shared Commitment model (type: vibe | standup) with a stake- and-settle engine, and a phase-by-phase My Journey tab. - My Journey tab: Standups / ViBe / SPA / Projects cards + internship plan (JourneyPlan, JourneyProgress; SPA & Projects are Samagama placeholders). - Commitments hub: accordion, one card per phase, one active per phase. - ViBe commitment migrated onto the shared Commitment model (debit-stake). - Standup commitment (new): weekly, attendance-only, keep-the-stake — tiers 81-90 (stake 20) / 91-100 (stake 50), confidence 2x/3x/4x, 6 sessions/week. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/src/main.jsx | 437 ++++++++++++++++++++++++++++++- client/src/styles.css | 126 +++++++++ server/models/Commitment.js | 41 +++ server/models/JourneyPlan.js | 13 + server/models/JourneyProgress.js | 19 ++ server/models/VibeProgress.js | 16 ++ server/scripts/seedVibeDummy.js | 155 +++++++++++ server/server.js | 121 ++++++++- server/services/journey.js | 95 +++++++ server/services/standup.js | 114 ++++++++ server/services/vibe.js | 149 +++++++++++ 11 files changed, 1284 insertions(+), 2 deletions(-) create mode 100644 server/models/Commitment.js create mode 100644 server/models/JourneyPlan.js create mode 100644 server/models/JourneyProgress.js create mode 100644 server/models/VibeProgress.js create mode 100644 server/scripts/seedVibeDummy.js create mode 100644 server/services/journey.js create mode 100644 server/services/standup.js create mode 100644 server/services/vibe.js diff --git a/client/src/main.jsx b/client/src/main.jsx index cebf2cc..7404c8d 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -279,9 +279,13 @@ function StudentView({ profile, onBack }) { - + {tab === 'bank' && } {tab === 'polls' && } + {tab === 'journey' && student.eligibleForVibeGoals && } + {tab === 'vibe' && student.eligibleForVibeGoals && } {tab === 'leaderboard' && } ); @@ -505,6 +509,437 @@ 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. +function MyJourney({ student, setTab }) { + const email = student.email; + const [data, setData] = useState(null); + const [plan, setPlan] = useState({ vibeBy: '', spaBy: '', projectBy: '' }); + const [savedMsg, setSavedMsg] = useState(false); + + const load = async () => { + const r = await fetch(`${API}/journey/state?email=${encodeURIComponent(email)}`); + const j = await r.json(); + setData(j); + if (j.plan) setPlan({ vibeBy: toInput(j.plan.vibeBy), spaBy: toInput(j.plan.spaBy), projectBy: toInput(j.plan.projectBy) }); + }; + useEffect(() => { load(); }, [email]); + + const savePlan = async () => { + const r = await fetch(`${API}/journey/plan`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, ...plan }) + }); + if (r.ok) { setData(await r.json()); setSavedMsg(true); setTimeout(() => setSavedMsg(false), 2000); } + }; + + if (!data) return
Loading your journey…
; + if (!data.eligible) return
My Journey isn’t available for your cohort yet.
; + + const { standups, vibe, spa, projects } = data; + const spaPct = spa.total ? Math.round(spa.solved / spa.total * 100) : 0; + + return ( +
+
+

My internship plan

+

Four phases to your Summership. Set the dates you’re aiming for — your cards below track you against them. (SP is staked separately in the Commitments tab.)

+
+ + + + + {savedMsg && ✓ Saved} +
+
+ +
+ {/* Phase 1 — Standups */} +
+
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} +
+
+ + {/* Phase 2 — ViBe */} +
+
2

ViBe courses

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

{vibe.clearedCount}/{vibe.totalCourses} courses complete · plan: by {fmtDate(data.plan.vibeBy)}

+
+ {vibe.ladder.map(l => ( +
+ {l.cleared ? '✓' : `${l.pct}%`}{l.name} +
+ ))} +
+
+ {vibe.current + ? Now: {vibe.current.name} — {vibe.current.pct}% + : All courses complete 🎉} + {vibe.activeCommitment && Active commitment: +{vibe.activeCommitment.goalPct}%} + +
+
+ + {/* Phase 3 — SPA (placeholder) */} +
+
3

SPA — Matrix Mystics

SP soon
+

53-problem set · plan: by {fmtDate(data.plan.spaBy)}

+
{spa.solved}/ {spa.total} solved
+
+
+ {spa.spaPoints} SPA points + SP rule pending Samagama data +
+
+ + {/* Phase 4 — Projects (placeholder) */} +
+
4

Projects

SP soon
+

Pull requests · plan: by {fmtDate(data.plan.projectBy)}

+
+
{projects.prsRaised}PRs raised
+
{projects.prsMerged}PRs merged
+
+
+ SP rule pending Samagama data +
+
+
+
+ ); +} + +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: 'Pledge to raise your current course’s completion by X% before a deadline.', ready: true }, + { key: 'standup', name: 'Standups', blurb: 'Pledge to attend all of this week’s standups at a chosen attendance tier.', ready: true }, + { 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 }) { + const [open, setOpen] = useState('vibe'); + return ( +
+
+

Commitments

+

Stake SP on a goal in any phase — hit it by the deadline and win your stake back multiplied; miss and you lose a penalty on top. One active commitment per phase (up to four running at once).

+
+ {COMMITMENT_TYPES.map(t => { + const isOpen = open === t.key; + return ( +
+ + {isOpen && ( +
+ {t.ready + ? (t.key === 'vibe' ? : ) + :

{t.blurb}
Coming soon — same stake-and-win mechanic as ViBe, 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 e135100..0230f7d 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -514,3 +514,129 @@ 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 (accordion, one card per phase) --------------------- */ +.cm { display: grid; gap: 12px; } +.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/server/models/Commitment.js b/server/models/Commitment.js new file mode 100644 index 0000000..950bb91 --- /dev/null +++ b/server/models/Commitment.js @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..74fa23f --- /dev/null +++ b/server/models/JourneyPlan.js @@ -0,0 +1,13 @@ +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 }, + 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 new file mode 100644 index 0000000..ea49c64 --- /dev/null +++ b/server/models/JourneyProgress.js @@ -0,0 +1,19 @@ +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/VibeProgress.js b/server/models/VibeProgress.js new file mode 100644 index 0000000..187d90c --- /dev/null +++ b/server/models/VibeProgress.js @@ -0,0 +1,16 @@ +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/seedVibeDummy.js b/server/scripts/seedVibeDummy.js new file mode 100644 index 0000000..2227e28 --- /dev/null +++ b/server/scripts/seedVibeDummy.js @@ -0,0 +1,155 @@ +// 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 11f6e4f..737b9f7 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,10 @@ 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 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'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -225,7 +229,8 @@ async function studentPayload(student) { leaderboardGroup: myGroup, leaderboardGroupLabel: groupLabel(myGroup), surveyCompleted: Boolean(student.surveyCompleted), - poll2Completed: Boolean(student.poll2Completed) + poll2Completed: Boolean(student.poll2Completed), + eligibleForVibeGoals: isVibeEligible(student) }, transactions, polls, @@ -270,6 +275,120 @@ 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) => { + const student = await vibeStudent(req); + if (!student || !isVibeEligible(student)) return res.status(403).json({ error: 'Not eligible for ViBe Goals.' }); + const { course, goalPct, stake, multiplier, deadline } = req.body || {}; + const state = await buildVibeState(student); + const v = validateBet({ state, course, goalPct: +goalPct, stake: +stake, multiplier: +multiplier, deadline }); + if (v.errs.length) return res.status(400).json({ error: v.errs.join(' ') }); + const c = courseByKey(course); + // debit the stake now (the "cost of the bet"), visible in the SP Bank + await applySpDelta(student.email, -(+stake), + `Staked ${stake} SP on ViBe goal: +${goalPct}% ${c ? c.name : course} (${multiplier}×)`); + await Commitment.create({ + email: student.email, type: 'vibe', debited: true, + course, goalPct: +goalPct, baselinePct: v.baselinePct, + deadline: new Date(deadline), stake: +stake, multiplier: +multiplier, + potentialWin: v.win, potentialLoss: v.loss, reserved: v.loss, status: 'active', + label: `+${goalPct}% ${c ? c.name : course} (stake ${stake} @ ${multiplier}×)` + }); + const fresh = await Student.findOne({ email: student.email }).lean(); + res.json(await buildVibeState(fresh)); +}); + +api.put('/vibe/bet/:id', async (req, res) => { + const student = await vibeStudent(req); + if (!student || !isVibeEligible(student)) return res.status(403).json({ error: 'Not eligible.' }); + const bet = await Commitment.findOne({ _id: req.params.id, email: student.email, type: 'vibe', status: 'active' }); + if (!bet) return res.status(404).json({ error: 'No active bet to edit.' }); + const { goalPct, stake, multiplier } = req.body || {}; // deadline & course are NOT editable + const state = await buildVibeState(student); + const v = validateBet({ state, course: bet.course, goalPct: +goalPct, stake: +stake, multiplier: +multiplier, ignoreActive: true }); + if (v.errs.length) return res.status(400).json({ error: v.errs.join(' ') }); + // reconcile the already-debited stake: refund the difference (old − new) + const stakeDiff = bet.stake - (+stake); + if (stakeDiff !== 0) { + const c = courseByKey(bet.course); + await applySpDelta(student.email, stakeDiff, + `ViBe goal edited — stake ${bet.stake}→${stake} on +${goalPct}% ${c ? c.name : bet.course}`); + } + Object.assign(bet, { goalPct: +goalPct, stake: +stake, multiplier: +multiplier, + potentialWin: v.win, potentialLoss: v.loss, reserved: v.loss }); + await bet.save(); + const fresh = await Student.findOne({ email: student.email }).lean(); + res.json(await buildVibeState(fresh)); +}); + +// DEMO: resolve a bet (no live settlement cron locally). result = 'won' | 'lost'. +api.post('/vibe/bet/:id/settle', async (req, res) => { + const student = await vibeStudent(req); + if (!student) return res.status(404).json({ error: 'Student not found' }); + const bet = await Commitment.findOne({ _id: req.params.id, email: student.email, type: 'vibe', status: 'active' }); + if (!bet) return res.status(404).json({ error: 'No active bet.' }); + const result = req.body?.result === 'lost' ? 'lost' : 'won'; + await settleBetDemo(bet, result); + const fresh = await Student.findOne({ email: student.email }).lean(); + res.json(await buildVibeState(fresh)); +}); + +// ---- 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) => { + const student = await vibeStudent(req); + if (!student || !isVibeEligible(student)) return res.status(403).json({ error: 'Not eligible for standup commitments.' }); + const { tierKey, multiplier } = req.body || {}; + const r = await placeStandup(student, { tierKey, multiplier }); + if (r.error) return res.status(400).json({ error: r.error }); + res.json(await buildStandupState(student)); +}); + +// DEMO: resolve a standup commitment (no live weekly settlement cron yet). +api.post('/standup/commit/:id/settle', async (req, res) => { + const student = await vibeStudent(req); + if (!student) return res.status(404).json({ error: 'Student not found' }); + const c = await Commitment.findOne({ _id: req.params.id, email: student.email, type: 'standup', status: 'active' }); + if (!c) return res.status(404).json({ error: 'No active standup commitment.' }); + await settleStandupDemo(c, req.body?.result === 'lost' ? 'lost' : 'won'); + const fresh = await Student.findOne({ email: student.email }).lean(); + res.json(await buildStandupState(fresh)); +}); + +// ---- 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' }); + if (!isVibeEligible(student)) return res.json({ eligible: false }); + res.json(await buildJourneyState(student)); +}); + +api.put('/journey/plan', async (req, res) => { + const student = await vibeStudent(req); + if (!student || !isVibeEligible(student)) return res.status(403).json({ error: 'Not eligible for My Journey.' }); + 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(); diff --git a/server/services/journey.js b/server/services/journey.js new file mode 100644 index 0000000..f5bcc05 --- /dev/null +++ b/server/services/journey.js @@ -0,0 +1,95 @@ +// "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 } from './vibe.js'; + +export const SPA_TOTAL = 53; + +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(); + const vibe = { + ladder: v.ladder, + current: v.current, + clearedCount: v.ladder.filter(l => l.cleared).length, + totalCourses: v.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(); + + return { + eligible: true, + name: student.name, + totalSp: student.totalSp || 0, + plan: { + vibeBy: plan?.vibeBy || null, + spaBy: plan?.spaBy || null, + projectBy: plan?.projectBy || null + }, + phaseSp: { standups: standups.sp, vibe: vibe.sp, spa: spa.sp, projects: projects.sp }, + standups, vibe, spa, projects + }; +} + +// Upsert the student's self-declared plan dates. Only the three date fields are set. +export async function saveJourneyPlan(email, { vibeBy, spaBy, projectBy }) { + const set = {}; + const parse = d => (d ? new Date(d) : null); + set.vibeBy = parse(vibeBy); + set.spaBy = parse(spaBy); + set.projectBy = parse(projectBy); + await JourneyPlan.updateOne({ email }, { $set: set }, { upsert: true }); +} diff --git a/server/services/standup.js b/server/services/standup.js new file mode 100644 index 0000000..4975e5b --- /dev/null +++ b/server/services/standup.js @@ -0,0 +1,114 @@ +// 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/vibe.js b/server/services/vibe.js new file mode 100644 index 0000000..a4e5572 --- /dev/null +++ b/server/services/vibe.js @@ -0,0 +1,149 @@ +// 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() } }); +} From 9e4e13d0637e3fce786695cebf381b841c89c850 Mon Sep 17 00:00:00 2001 From: sakshivk Date: Wed, 22 Jul 2026 13:58:59 +0530 Subject: [PATCH 5/5] My Journey: show SPA & Projects cards as 'Coming soon' until data lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two phases have no Samagama data yet, so gate their card bodies on the pending flag — render a 'Coming soon' message instead of placeholder zeros. Flip pending=false in the journey service when real data arrives. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/src/main.jsx | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index 7404c8d..3a38e6c 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -593,29 +593,29 @@ function MyJourney({ student, setTab }) {
- {/* Phase 3 — SPA (placeholder) */} + {/* Phase 3 — SPA (data + SP rule pending Samagama) */}
-
3

SPA — Matrix Mystics

SP soon
+
3

SPA — Matrix Mystics

Coming soon

53-problem set · plan: by {fmtDate(data.plan.spaBy)}

-
{spa.solved}/ {spa.total} solved
-
-
- {spa.spaPoints} SPA points - SP rule pending Samagama data -
+ {spa.pending + ?

Your Matrix Mystics progress and SP will appear here soon — we’re wiring up the data.

+ : <> +
{spa.solved}/ {spa.total} solved
+
+
{spa.spaPoints} SPA points
+ }
- {/* Phase 4 — Projects (placeholder) */} + {/* Phase 4 — Projects (data + SP rule pending Samagama) */}
-
4

Projects

SP soon
+
4

Projects

Coming soon

Pull requests · plan: by {fmtDate(data.plan.projectBy)}

-
-
{projects.prsRaised}PRs raised
-
{projects.prsMerged}PRs merged
-
-
- SP rule pending Samagama data -
+ {projects.pending + ?

Your project PRs and SP will appear here soon — we’re wiring up the data.

+ :
+
{projects.prsRaised}PRs raised
+
{projects.prsMerged}PRs merged
+
}