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/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 && (
+
+ { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:open-share', { detail: { kind: 'final', data: j } })); }}>📤 Share
+ Close
+
+ )}
+
+ )}
+
+ );
+};
\ 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.
+
+
+ {busy ? 'Saving…' : '⬇️ Download Image'}
+ 🔗 Share on LinkedIn
+ {kind === 'final' && 📜 Print Certificate }
+ Close
+
+
+
+ )}
+
+ );
+};
\ 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) => (
+
+ ))}
+
+ onPrev && onPrev('close')} aria-label="Close">×
+ onPrev && onPrev('prev')} aria-label="Previous" />
+ onNext && onNext('next')} aria-label="Next" />
+
+ {slide.eyebrow}
+ {slide.title && {slide.title} }
+ {count.toLocaleString()}{slide.suffix || ''}
+ {slide.subtitle && {slide.subtitle}
}
+ {slide.trio && (
+
+ {slide.trio.map((t, i) => (
+
+ {t.label}
+ {t.value}
+
+ ))}
+
+ )}
+ {slide.cta && (
+
+ {slide.cta.map((c, i) => (
+ {c.label}
+ ))}
+
+ )}
+
+ {slide.decor === 'sparkles' && }
+ {slide.decor === 'confetti' && }
+ {slide.decor === 'rankline' && }
+
+ );
+}
+
+function SparkleField() {
+ return (
+
+ {Array.from({ length: 18 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function ConfettiBurst() {
+ const colors = ['#FBBF24','#F59E0B','#EC4899','#8B5CF6','#10B981','#3B82F6'];
+ return (
+
+ {Array.from({ length: 22 }).map((_, i) => {
+ const angle = (i / 22) * Math.PI * 2;
+ const r = 80 + (i % 5) * 18;
+ const tx = Math.cos(angle) * r;
+ const ty = Math.sin(angle) * r;
+ 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 && (
+
+ onOpenShare && onOpenShare('weekly', data)}>📤 Share
+ Close
+
+ )}
+
+ )}
+
+ );
+};
\ 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 cebf2cc..eea94af 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`;
@@ -264,11 +271,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 ? Back : }
@@ -279,10 +288,41 @@ function StudentView({ profile, onBack }) {
-
+
{tab === 'bank' &&
}
{tab === 'polls' &&
}
+ {tab === 'journey' && student.eligibleForVibeGoals &&
}
+ {tab === 'vibe' && student.eligibleForVibeGoals &&
}
{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) => (
+
{ setWeeklyOpen(true); }}
+ style={{ textAlign: 'left', padding: 10, border: '1px solid #d9e1ec', borderRadius: 10, background: '#fff', cursor: 'pointer' }}
+ >
+ Week of
+ {w.weekStartIso}
+
+ {w.sessionsAttended} sessions · {w.pollsAnswered} polls · +{w.spEarned} SP
+
+
+ ))}
+
+
+ )}
);
}
@@ -505,6 +545,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
;
+ 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 (
+
+
+
+
+ {/* 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}% }
+ setTab('vibe')}>Set a commitment →
+
+
+
+ {/* Phase 3 — SPA (data + SP rule pending Samagama) */}
+
+ 3
SPA — Matrix Mystics Coming soon
+ 53-problem set · plan: by {fmtDate(data.plan.spaBy)}
+ {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 (data + SP rule pending Samagama) */}
+
+ 4
Projects Coming soon
+ Pull requests · plan: by {fmtDate(data.plan.projectBy)}
+ {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
+
}
+
+
+
+ );
+}
+
+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 (
+
+ setOpen(isOpen ? null : t.key)}>
+ {isOpen ? '▾' : '▸'}
+ {t.name}
+ {!t.ready && coming soon }
+ {t.blurb}
+
+ {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
;
+ 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 && (
+
+
Course
+
Raise completion by
+
setForm({ ...form, goalPct: e.target.value })} />%
+
Allowed {cur.floorPct}%–{cur.remaining}% (floor → remaining) · ≈ {(g / 100 * cur.hours).toFixed(1)} h
+
+
Deadline
+ setForm({ ...form, deadline: e.target.value })} />
+ {editing ? 'Fixed — can’t be changed after placing.' : `Up to ${cfg.maxBetDays} days away.`}
+
+
Stake — {s} SP
+ setForm({ ...form, stake: e.target.value })} />
+ {cfg.stakeMin}–{cfg.stakeMax} SP.
+
+
Confidence multiplier
+
{cfg.multipliers.map(x =>
+ setForm({ ...form, multiplier: x })}>{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
+ ? <>Save changes
+ { setEditing(false); setErr(null); }}>Cancel >
+ : Place commitment }
+ {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 && { setForm({ goalPct: data.active.goalPct, stake: data.active.stake, multiplier: data.active.multiplier, deadline: form.deadline }); setEditing(true); }}>Edit commitment }
+ settle('won')}>Demo: Hit
+ settle('lost')}>Demo: Miss
+
+
+
+ ) : No active commitment right now — set one above.
}
+
+
+
+ Past commitments
+ {data.history.length ? (
+ Course Goal Stake Result Net SP
+ {data.history.map(b => (
+ {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
;
+ 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
+
+
Attendance tier (fixed stake)
+
{data.tiers.map(t =>
+ setTierKey(t.key)}>{t.label} · stake {t.stake} )}
+
Higher tier = higher bar and bigger reward. Beating your tier still counts as a hit.
+
+
Confidence multiplier
+
{data.multipliers.map(x =>
+ setMultiplier(x)}>{x}× )}
+
+
+
Stake (fixed by tier) {stake}
+
If you HIT +{win} bonus, on top of attendance
+
If you MISS −{loss} penalty off your balance
+
+
+ Place commitment
+ {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}
+
+ settle('won')}>Demo: Hit
+ settle('lost')}>Demo: Miss
+
+
+
+ ) : No active standup commitment — set one above.
}
+
+
+
+ Past standup commitments
+ {data.history.length ? (
+ Week pledge Tier Result SP
+ {data.history.map(c => (
+ {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);
@@ -846,5 +1317,37 @@ function SurveyModal({ survey, student, onDone, statusPath = '/survey/status', c
);
}
+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( );
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/pipeline/sp-rubric-build-mirror.cjs b/pipeline/sp-rubric-build-mirror.cjs
index 3685335..a72f957 100644
--- a/pipeline/sp-rubric-build-mirror.cjs
+++ b/pipeline/sp-rubric-build-mirror.cjs
@@ -65,7 +65,21 @@ const APPLY = process.env.APPLY === '1';
// 09:05 IST = 03:35 UTC. wEnd = min(first-instance-end, 11:00 IST). Per-day end overrides (IST) take precedence.
const WINDOW_END_OVERRIDE_IST = { '2026-05-22': '11:00' };
+// From EVENING_CUTOVER the daily standup moved from the morning (09:05-11:00 IST)
+// to the evening. On/after this date, score presence in [EVENING_WSTART_IST,
+// EVENING_WEND_IST] IST (5-min join grace, mirroring the old morning window) and
+// pick the mandatory meeting that overlaps THAT window (an all-day/leftover
+// morning room must not steal the slot). Dates before the cutover are unchanged.
+const EVENING_CUTOVER = '2026-07-16';
+const EVENING_WSTART_IST = '20:05';
+const EVENING_WEND_IST = '21:00';
const GRACE_DATE = '2026-06-06'; // exceptional: 1 min join = full att + full poll
+// Polls also moved off Zoom to the Spandan evening classroom at the evening
+// cutover. On/after this date the poll (B) score comes from `spandan_polls`
+// (correctness, percentiled to the day's top scorer); strictly before it, from
+// the frozen `zoom_polls` mirror (participation) exactly as history has it — so
+// old poll SP is never disturbed. Same date as the evening attendance cutover.
+const SPANDAN_CUTOFF = process.env.SPANDAN_CUTOFF || EVENING_CUTOVER;
const STAFF = new Set([
'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com',
'sudarshansudarshan@gmail.com', 'sudarshan@iitrpr.ac.in', 'rajankrsna@gmail.com',
@@ -116,16 +130,53 @@ 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) });
}
// 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
@@ -146,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;
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() } });
+}