From 6fc42e3b32139d0b382a9bd3a9827fac8faf6bb2 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 18 Jul 2026 17:04:07 +0530 Subject: [PATCH 01/31] feat: add Your Week Is Ready replay system - WeeklyReplayModal: slide-show recap of weekly attendance, polls, SP, streaks - FinalJourneyModal: end-of-program summary of full journey - ShareCard: exportable achievement card with SP, rank, streak stats - EntryPill: floating navigation pill in the dashboard header - ReplayEngine: builds replay data from existing profile (no new backend) - Replay tab in dashboard showing history of past weekly recaps - Dependencies: framer-motion (animations), html2canvas (ShareCard export) --- client/package.json | 2 + client/src/components/replay/EntryPill.tsx | 37 ++++ .../components/replay/FinalJourneyModal.tsx | 57 ++++++ client/src/components/replay/ShareCard.tsx | 101 ++++++++++ client/src/components/replay/StorySlide.tsx | 134 +++++++++++++ .../components/replay/WeeklyReplayModal.tsx | 49 +++++ client/src/components/replay/replay.css | 48 +++++ client/src/components/replay/replayEngine.js | 178 ++++++++++++++++++ client/src/main.jsx | 66 ++++++- 9 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 client/src/components/replay/EntryPill.tsx create mode 100644 client/src/components/replay/FinalJourneyModal.tsx create mode 100644 client/src/components/replay/ShareCard.tsx create mode 100644 client/src/components/replay/StorySlide.tsx create mode 100644 client/src/components/replay/WeeklyReplayModal.tsx create mode 100644 client/src/components/replay/replay.css create mode 100644 client/src/components/replay/replayEngine.js diff --git a/client/package.json b/client/package.json index 9d7a24f..15cb766 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "framer-motion": "^11.15.0", + "html2canvas": "^1.4.1", "vite": "^5.4.11", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/client/src/components/replay/EntryPill.tsx b/client/src/components/replay/EntryPill.tsx new file mode 100644 index 0000000..18a5d99 --- /dev/null +++ b/client/src/components/replay/EntryPill.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +export const EntryPill = ({ kind = 'weekly', onClick }) => { + if (kind === 'final') { + return ( + + + Your Spurti Journey is Ready! + + + ); + } + return ( + + + Your Week is Ready! + + + ); +}; \ No newline at end of file diff --git a/client/src/components/replay/FinalJourneyModal.tsx b/client/src/components/replay/FinalJourneyModal.tsx new file mode 100644 index 0000000..45d7fe3 --- /dev/null +++ b/client/src/components/replay/FinalJourneyModal.tsx @@ -0,0 +1,57 @@ +import React, { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { StorySlide } from './StorySlide'; +import { buildFinalJourney } from './replayEngine'; + +function makeJourneySlides(j) { + if (!j) return []; + return [ + { eyebrow: '🌌 YOUR SPURTI JOURNEY', title: j.studentName || 'Your Story', count: 1, suffix: '', subtitle: 'A complete picture of what you did and who you became.', gradient: 'linear-gradient(160deg, #020617 0%, #1E1B4B 100%)', decor: 'sparkles', autoMs: 3500 }, + { eyebrow: '🌱 THE BEGINNING', count: j.startRank, suffix: '', subtitle: 'You started here. Small numbers, big journey ahead.', gradient: 'linear-gradient(160deg, #1E293B 0%, #334155 100%)', autoMs: 4000 }, + { eyebrow: 'πŸ“ˆ THE CLIMB', count: 1, suffix: '', subtitle: 'From your starting rank to where you ended up.', gradient: 'linear-gradient(160deg, #1E3A8A 0%, #7C3AED 100%)', autoMs: 5000, decor: 'rankline', from: j.startRank, to: j.endRank }, + { eyebrow: 'πŸ‘‘ THE REVEAL', count: j.endRank, suffix: '', subtitle: 'You ended at this rank. The climb was real.', gradient: 'linear-gradient(160deg, #92400E 0%, #FBBF24 100%)', autoMs: 4500, decor: 'confetti' }, + { eyebrow: 'πŸ’Ž SP EARNED', count: j.totalSp, suffix: ' SP', subtitle: 'Every point is a footprint. Look at all of them.', gradient: 'linear-gradient(160deg, #78350F 0%, #D97706 100%)', autoMs: 4500 }, + { eyebrow: 'πŸ“Š TOTAL ACTIVITY', count: 3, subtitle: 'The compound effect of showing up.', gradient: 'linear-gradient(160deg, #0E7490 0%, #164E63 100%)', autoMs: 4500, trio: [{ label: 'Sessions', value: j.sessionsAttended }, { label: 'Polls Answered', value: j.pollsAnswered }, { label: 'Longest Streak', value: (j.longestStreak || 0) + ' d' }] }, + { eyebrow: 'πŸ† BEST ACHIEVEMENT', count: 1, suffix: '', subtitle: j.bestAchievement, gradient: 'linear-gradient(160deg, #581C87 0%, #BE185D 100%)', autoMs: 5000 }, + { eyebrow: '🧬 EVOLUTION', count: 1, suffix: '', subtitle: 'You started as ' + (j.personaEvolution && j.personaEvolution.from) + ' β€” you became ' + (j.personaEvolution && j.personaEvolution.to) + '.', gradient: 'linear-gradient(160deg, #312E81 0%, #DB2777 100%)', autoMs: 5000 }, + { eyebrow: 'πŸŽ‰ THANK YOU', title: 'For Growing With Spurti', count: 1, suffix: '', subtitle: 'Your story matters. Share it, or save it forever.', gradient: 'linear-gradient(160deg, #7C2D12 0%, #F59E0B 100%)', autoMs: 4500, decor: 'confetti', + cta: [ + { label: 'πŸ“€ Share', onClick: () => { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:open-share', { detail: { kind: 'final', data: j } })); } }, + { label: 'Close', onClick: () => { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:close')); }, primary: true } + ] + } + ]; +} + +export const FinalJourneyModal = ({ open, onClose, profile, studentName }) => { + const j = useMemo(() => profile ? { ...buildFinalJourney(profile), studentName: studentName || (profile.student && profile.student.name) || 'You' } : null, [profile, studentName]); + const slides = useMemo(() => makeJourneySlides(j), [j]); + const [idx, setIdx] = useState(0); + function go(next) { + if (next === 'close') return onClose && onClose(); + if (next === 'prev') setIdx(i => Math.max(0, i - 1)); + if (next === 'next') setIdx(i => Math.min(slides.length - 1, i + 1)); + } + function goNextAuto() { setIdx(i => Math.min(slides.length - 1, i + 1)); } + return ( + + {open && ( + +
+ + {slides[idx] && ( + + )} + +
+ {idx === slides.length - 1 && ( +
+ + +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/client/src/components/replay/ShareCard.tsx b/client/src/components/replay/ShareCard.tsx new file mode 100644 index 0000000..5bd6d0c --- /dev/null +++ b/client/src/components/replay/ShareCard.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import html2canvas from 'html2canvas'; + +function buildWeeklyShareHTML(data, studentName) { + return '
' + + '
πŸ“… My Week in Spurti
' + + '
' + (studentName || 'My') + ' Week in Spurti
' + + '
' + + statBlock(data.sessionsAttended || 0, 'Sessions') + + statBlock(data.pollsAnswered || 0, 'Polls') + + statBlock('+' + (data.spEarned || 0), 'SP') + + '
' + + '
Most improved: ' + (data.most_improved || 'β€”') + '
' + + '
Spurti Β· VLED Summer
' + + '
'; +} + +function statBlock(value, label) { + return '
' + + '
' + value + '
' + + '
' + label + '
' + + '
'; +} + +function buildFinalShareHTML(j, studentName) { + return '
' + + '
🌌 My Spurti Journey
' + + '
' + (studentName || 'I') + ' completed the journey
' + + '
' + + statBlock('#' + j.startRank, 'Start') + + statBlock('#' + j.endRank, 'End') + + statBlock('+' + j.totalSp, 'SP Earned') + + statBlock(j.sessionsAttended, 'Sessions') + + '
' + + '
πŸ† ' + j.bestAchievement + '
' + + '
Spurti Β· VLED Summer
' + + '
'; +} + +export const ShareCard = ({ open, onClose, kind = 'weekly', data, studentName }) => { + const ref = useRef(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!open) return; + if (!ref.current) return; + ref.current.innerHTML = kind === 'final' ? buildFinalShareHTML(data, studentName) : buildWeeklyShareHTML(data, studentName); + }, [open, kind, data, studentName]); + + async function download() { + if (!ref.current) return; + setBusy(true); + try { + const canvas = await html2canvas(ref.current.firstElementChild, { backgroundColor: null, scale: 2 }); + const url = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = url; + a.download = (kind === 'final' ? 'spurti-journey' : 'spurti-week') + '-' + (studentName || 'card') + '.png'; + document.body.appendChild(a); + a.click(); + a.remove(); + } finally { setBusy(false); } + } + + function shareLinkedIn() { + const text = encodeURIComponent('Just completed my Spurti journey! πŸŽ‰ ' + (kind === 'final' ? data.bestAchievement : 'What a week!') + ' #Spurti'); + const url = encodeURIComponent(typeof window !== 'undefined' ? window.location.href : 'https://spurti.app'); + window.open('https://www.linkedin.com/sharing/share-offsite/?url=' + url + '&summary=' + text, '_blank'); + } + + function printCertificate() { + const w = window.open('', '_blank', 'width=900,height=1200'); + if (!w) return; + const ach = (kind === 'final' && data) ? data.bestAchievement : 'Steady Contributor'; + const html = 'Certificate

πŸ† Certificate of Completion

VLED Summer Internship Β· Spurti

This certifies that

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

has successfully completed the Spurti journey with the title

' + ach + '

Issued by Spurti Β· VLED Summer

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

πŸ“€ Share Your {kind === 'final' ? 'Journey' : 'Week'}

+

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

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

πŸ“Ό Replay History

+

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

+
+ {buildReplayHistory(profile, 6).map((w, i) => ( + + ))} +
+
+ )}
); } @@ -837,5 +869,37 @@ function SurveyModal({ survey, student, onDone }) { ); } +function ReplaySection({ profile }) { + const [weeklyOpen, setWeeklyOpen] = useState(false); + const [finalOpen, setFinalOpen] = useState(false); + const [share, setShare] = useState(null); + const [unlocked, setUnlocked] = useState(false); + useEffect(() => { + setUnlocked(isFinalJourneyUnlocked(profile || {})); + }, [profile]); + if (!profile || !profile.student) return null; + return ( + <> +
+ setWeeklyOpen(true)} /> + {unlocked && ( + + setFinalOpen(true)} /> + + )} +
+ setWeeklyOpen(false)} profile={profile} onOpenShare={() => setShare({ kind: 'weekly' })} /> + setFinalOpen(false)} profile={profile} studentName={profile.student.name} onOpenShare={() => setShare({ kind: 'final' })} /> + {share && ( + setShare(null)} + /> + )} + + ); +} createRoot(document.getElementById('root')).render(); From 37e123ddc6ad17776fe8156880a23e2d86e42ccd Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 19:32:25 +0530 Subject: [PATCH 02/31] feat: add premium Share & Export modal for weekly achievement - ShareAchievementModal: React-rendered premium card with brand header, achievement title, 4-stat grid (SP / rank / sessions / polls), badges, spurti branding footer - PNG export at 2x scale via html2canvas (high-DPI social-share ready) - PDF export via jspdf (Letter landscape, centered, preserves design) - Quick share to LinkedIn / X (Twitter) / WhatsApp / Telegram / Email - Copy-link with fallback for non-secure contexts - Wire: new 'Share Achievement' button in WeeklyReplayModal endbar - Builds a payload from replay data + profile (name, weekly SP, weekly rank, total SP, cohort size, badges, week range, achievement title) - Achievement title is derived from weekly SP (Legend / Champion / Achiever / Builder / Starter) - Dependencies: +jspdf@^2.5.2 --- client/package-lock.json | 301 +++++++++++++--- client/package.json | 8 +- .../replay/ShareAchievementModal.tsx | 322 ++++++++++++++++++ .../components/replay/WeeklyReplayModal.tsx | 80 ++++- client/src/components/replay/replay.css | 267 ++++++++++++++- package-lock.json | 8 +- package.json | 2 +- 7 files changed, 922 insertions(+), 66 deletions(-) create mode 100644 client/src/components/replay/ShareAchievementModal.tsx diff --git a/client/package-lock.json b/client/package-lock.json index b8bb10c..8dc4aca 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,9 @@ "version": "1.0.0", "dependencies": { "@vitejs/plugin-react": "^4.3.4", + "framer-motion": "^11.15.0", + "html2canvas": "^1.4.1", + "jspdf": "^2.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", "vite": "^5.4.11" @@ -43,6 +46,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -233,6 +237,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -782,9 +795,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +808,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -814,9 +821,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +834,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -846,9 +847,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -862,9 +860,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -878,9 +873,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -894,9 +886,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -910,9 +899,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -926,9 +912,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -942,9 +925,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -958,9 +938,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -974,9 +951,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1108,6 +1082,13 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1128,6 +1109,27 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -1159,6 +1161,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1173,6 +1176,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -1193,12 +1208,53 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1216,6 +1272,13 @@ } } }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -1269,6 +1332,39 @@ "node": ">=6" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1292,6 +1388,19 @@ "node": ">=6.9.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1322,6 +1431,24 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1343,6 +1470,21 @@ "yallist": "^3.0.2" } }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1376,6 +1518,13 @@ "node": ">=18" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1410,11 +1559,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1427,6 +1587,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1444,6 +1605,23 @@ "node": ">=0.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -1515,6 +1693,41 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1545,11 +1758,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/client/package.json b/client/package.json index 15cb766..3bdbe07 100644 --- a/client/package.json +++ b/client/package.json @@ -11,9 +11,9 @@ "@vitejs/plugin-react": "^4.3.4", "framer-motion": "^11.15.0", "html2canvas": "^1.4.1", - "vite": "^5.4.11", + "jspdf": "^2.5.2", "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": {} + "react-dom": "^18.3.1", + "vite": "^5.4.11" + } } diff --git a/client/src/components/replay/ShareAchievementModal.tsx b/client/src/components/replay/ShareAchievementModal.tsx new file mode 100644 index 0000000..d537c19 --- /dev/null +++ b/client/src/components/replay/ShareAchievementModal.tsx @@ -0,0 +1,322 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import html2canvas from 'html2canvas'; +import jsPDF from 'jspdf'; + +// ============================================================ +// Premium Share & Export modal for "Your Week in Spurti" +// Generates a React-rendered achievement card, captures it +// with html2canvas at 2x scale, and offers: +// - PNG download +// - PDF download (Letter, landscape, centered) +// - Quick share to LinkedIn / X / WhatsApp / Telegram / Email +// - Copy-link to clipboard +// ============================================================ + +const TITLE_MAP = [ + { min: 800, label: 'πŸ† Spurti Legend' }, + { min: 500, label: 'πŸ”₯ Spurti Champion' }, + { min: 300, label: '⚑ Spurti Achiever' }, + { min: 150, label: '🌱 Spurti Builder' }, + { min: 0, label: '✨ Spurti Starter' } +]; + +function deriveTitle(weeklySp) { + return (TITLE_MAP.find(t => weeklySp >= t.min) || TITLE_MAP[TITLE_MAP.length - 1]).label; +} + +function fmtRange(startIso, endIso) { + if (!startIso || !endIso) return 'This Week'; + const fmt = (iso) => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + return `${fmt(startIso)} – ${fmt(endIso)}`; +} + +// ============================================================ +// The capture target β€” must remain a clean DOM tree (no +// refs into framer-motion children) so html2canvas can +// snapshot it deterministically. +// ============================================================ +const AchievementCard = React.forwardRef(function AchievementCard({ payload }, ref) { + const { + studentName, weekLabel, weeklySp, weeklyRank, totalSp, cohortSize, + badges = [], achievementTitle, sessionsAttended, pollsAnswered + } = payload; + + return ( +
+
+
+
+ +
+
+ S +
+
SPURTI
+
VLED Summer Β· Spurti Points
+
+
+
{weekLabel}
+
+ +
+
WEEKLY ACHIEVEMENT
+
{achievementTitle}
+
{studentName}
+
+ +
+
+
Weekly SP
+
+{weeklySp}
+
{totalSp} total
+
+
+
Weekly Rank
+
#{weeklyRank}
+
of {cohortSize}
+
+
+
Sessions
+
{sessionsAttended}
+
attended
+
+
+
Polls
+
{pollsAnswered}
+
answered
+
+
+ + {badges.length > 0 && ( +
+
BADGES EARNED
+
+ {badges.slice(0, 5).map((b, i) => ( + {b} + ))} +
+
+ )} + +
+ SPURTI + Built through showing up. + spurti.app +
+
+ ); +}); + +// ============================================================ +// Public modal +// ============================================================ +export function ShareAchievementModal({ open, onClose, payload }) { + const cardRef = useRef(null); + const [busy, setBusy] = useState(null); // 'png' | 'pdf' | null + const [copied, setCopied] = useState(false); + + const safePayload = useMemo(() => ({ + studentName: 'Student', + weekLabel: 'This Week', + weeklySp: 0, + weeklyRank: 'β€”', + totalSp: 0, + cohortSize: 'β€”', + badges: [], + achievementTitle: 'Spurti Builder', + sessionsAttended: 0, + pollsAnswered: 0, + ...(payload || {}) + }), [payload]); + + const shareText = useMemo(() => { + const p = safePayload; + const title = p.achievementTitle || deriveTitle(p.weeklySp); + return `${p.studentName} earned the ${title} on Spurti β€” +${p.weeklySp} SP this week (Rank #${p.weeklyRank}). Built through showing up.`; + }, [safePayload]); + + const shareUrl = typeof window !== 'undefined' ? window.location.origin + '/spurti/' : 'https://spurti.app/'; + const encodedUrl = encodeURIComponent(shareUrl); + const encodedText = encodeURIComponent(shareText); + const encodedSubject = encodeURIComponent(`My Spurti Weekly Achievement β€” +${safePayload.weeklySp} SP`); + + const captureCanvas = useCallback(async (scale = 2) => { + if (!cardRef.current) return null; + return html2canvas(cardRef.current, { + scale, + backgroundColor: null, + useCORS: true, + logging: false + }); + }, []); + + const downloadFile = (blob, filename) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + }; + + const exportPNG = useCallback(async () => { + setBusy('png'); + try { + const canvas = await captureCanvas(2); + if (!canvas) return; + canvas.toBlob((blob) => { + if (!blob) return; + downloadFile(blob, `spurti-week-${safePayload.studentName?.replace(/\s+/g, '_') || 'achievement'}.png`); + }, 'image/png'); + } finally { + setBusy(null); + } + }, [captureCanvas, safePayload.studentName]); + + const exportPDF = useCallback(async () => { + setBusy('pdf'); + try { + const canvas = await captureCanvas(2); + if (!canvas) return; + const imgData = canvas.toDataURL('image/png'); + // Letter landscape: 11 x 8.5 in. Card is 1080x680 (β‰ˆ 1.59:1) β€” fits with margin. + const pdf = new jsPDF({ orientation: 'landscape', unit: 'in', format: 'letter' }); + const pageW = pdf.internal.pageSize.getWidth(); + const pageH = pdf.internal.pageSize.getHeight(); + const margin = 0.5; + const maxW = pageW - margin * 2; + const maxH = pageH - margin * 2; + const ratio = canvas.width / canvas.height; + let w = maxW; + let h = w / ratio; + if (h > maxH) { h = maxH; w = h * ratio; } + const x = (pageW - w) / 2; + const y = (pageH - h) / 2; + pdf.addImage(imgData, 'PNG', x, y, w, h, undefined, 'FAST'); + pdf.save(`spurti-week-${safePayload.studentName?.replace(/\s+/g, '_') || 'achievement'}.pdf`); + } finally { + setBusy(null); + } + }, [captureCanvas, safePayload.studentName]); + + const shareTo = { + linkedin: () => window.open( + `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}&summary=${encodedText}`, + '_blank', 'noopener,noreferrer,width=600,height=600' + ), + x: () => window.open( + `https://twitter.com/intent/tweet?text=${encodedText}&url=${encodedUrl}&via=spurti_app`, + '_blank', 'noopener,noreferrer,width=600,height=600' + ), + whatsapp: () => window.open( + `https://api.whatsapp.com/send?text=${encodedText}%20${encodedUrl}`, + '_blank', 'noopener,noreferrer' + ), + telegram: () => window.open( + `https://t.me/share/url?url=${encodedUrl}&text=${encodedText}`, + '_blank', 'noopener,noreferrer' + ), + email: () => { + const body = `${shareText}\n\n${shareUrl}`; + window.location.href = `mailto:?subject=${encodedSubject}&body=${encodeURIComponent(body)}`; + }, + copyLink: async () => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(shareUrl); + } else { + // Fallback for older browsers / non-secure contexts. + const ta = document.createElement('textarea'); + ta.value = shareUrl; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + } + setCopied(true); + setTimeout(() => setCopied(false), 2200); + } catch { + setCopied(false); + } + } + }; + + return ( + + {open && ( + + e.stopPropagation()} + > + +
+

πŸ“€ Share Your Achievement

+

Export as PNG or PDF, or post directly to your favorite platform.

+
+ +
+ +
+ +
+ + +
+ +
+ Quick share +
+ + + + + + +
+
+
+
+ )} +
+ ); +} diff --git a/client/src/components/replay/WeeklyReplayModal.tsx b/client/src/components/replay/WeeklyReplayModal.tsx index f818eeb..af5f62e 100644 --- a/client/src/components/replay/WeeklyReplayModal.tsx +++ b/client/src/components/replay/WeeklyReplayModal.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { StorySlide } from './StorySlide'; import { buildWeeklyReplay } from './replayEngine'; +import { ShareAchievementModal } from './ShareAchievementModal'; function makeSlides(data) { if (!data) return []; @@ -19,31 +20,76 @@ 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); + const [achvOpen, setAchvOpen] = useState(false); 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)); } + + // Build the achievement payload from replay data + profile. + const achvPayload = useMemo(() => { + if (!profile) return null; + const studentName = profile.student?.name || 'Student'; + const badges = Array.isArray(profile.badges) ? profile.badges : []; + const weeklySp = data?.spEarned ?? 0; + const weeklyRank = data?.highestRank ?? profile.student?.rank ?? 'β€”'; + const sessionsAttended = data?.sessionsAttended ?? 0; + const pollsAnswered = data?.pollsAnswered ?? 0; + const totalSp = profile.student?.totalSp ?? 0; + const cohortSize = profile.student?.cohortSize ?? 'β€”'; + const weekStart = data?.weekStartIso; + const weekEnd = data?.weekEndIso; + const fmtRange = (a, b) => { + if (!a || !b) return 'This Week'; + const fa = (iso) => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + return `${fa(a)} – ${fa(b)}`; + }; + const weekLabel = fmtRange(weekStart, weekEnd); + const title = (() => { + if (weeklySp >= 50) return 'πŸ† Spurti Legend'; + if (weeklySp >= 30) return 'πŸ”₯ Spurti Champion'; + if (weeklySp >= 15) return '⚑ Spurti Achiever'; + if (weeklySp >= 5) return '🌱 Spurti Builder'; + return '✨ Spurti Starter'; + })(); + return { studentName, weekLabel, weeklySp, weeklyRank, totalSp, cohortSize, badges, achievementTitle: title, sessionsAttended, pollsAnswered }; + }, [profile, data]); + return ( - - {open && ( - -
- - {slides[idx] && ( - - )} - -
- {idx === slides.length - 1 && ( -
- - + <> + + {open && ( + +
+ + {slides[idx] && ( + + )} +
- )} -
+ {idx === slides.length - 1 && ( +
+ + + +
+ )} + + )} +
+ {achvPayload && ( + setAchvOpen(false)} + payload={achvPayload} + /> )} - + ); }; \ No newline at end of file diff --git a/client/src/components/replay/replay.css b/client/src/components/replay/replay.css index bf26b9e..78f6bce 100644 --- a/client/src/components/replay/replay.css +++ b/client/src/components/replay/replay.css @@ -45,4 +45,269 @@ .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 +.share-modal__btn:disabled { opacity: 0.6; cursor: not-allowed; } + +/* ============================================================ + Premium Share & Export Modal + ============================================================ */ +.share-achv { + position: fixed; inset: 0; z-index: 1100; + background: rgba(15, 23, 42, 0.78); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: grid; place-items: center; + padding: 16px; + overflow-y: auto; +} +.share-achv__inner { + position: relative; + width: min(720px, 100%); + background: #ffffff; + border-radius: 18px; + box-shadow: 0 30px 60px rgba(15, 23, 42, 0.35), 0 4px 12px rgba(15, 23, 42, 0.15); + padding: 20px 22px 22px; + display: grid; gap: 16px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + max-height: calc(100vh - 32px); + overflow-y: auto; +} +.share-achv__close { + position: absolute; top: 10px; right: 12px; + width: 30px; height: 30px; + background: transparent; border: 0; + font-size: 22px; line-height: 1; + cursor: pointer; color: #6b7280; + border-radius: 50%; + transition: background 0.15s, color 0.15s; +} +.share-achv__close:hover { background: #f3f4f6; color: #1f2937; } + +.share-achv__head h2 { + margin: 0 0 4px; + font-size: 18px; font-weight: 800; color: #1f2937; +} +.share-achv__head p { + margin: 0; font-size: 12px; color: #6b7280; +} + +.share-achv__preview { + display: grid; place-items: center; + padding: 8px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; +} + +/* ===== The capture target β€” premium LinkedIn-ready card ===== */ +.achv-card { + position: relative; + width: 100%; + max-width: 640px; + aspect-ratio: 1080 / 680; + padding: 28px 32px 22px; + border-radius: 16px; + color: #ffffff; + background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.55) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.45) 0%, transparent 55%), + linear-gradient(135deg, #0F172A 0%, #312E81 60%, #4C1D95 100%); + overflow: hidden; + isolation: isolate; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.45), 0 4px 12px rgba(15, 23, 42, 0.25); + display: grid; + grid-template-rows: auto auto 1fr auto auto; + gap: 14px; +} +.achv-card__decor { + position: absolute; border-radius: 50%; filter: blur(60px); pointer-events: none; z-index: -1; +} +.achv-card__decor--1 { top: -40px; left: -40px; width: 220px; height: 220px; background: rgba(56, 189, 248, 0.35); } +.achv-card__decor--2 { bottom: -50px; right: -30px; width: 260px; height: 260px; background: rgba(244, 114, 182, 0.35); } +.achv-card__decor--3 { top: 30%; left: 40%; width: 180px; height: 180px; background: rgba(167, 139, 250, 0.25); transform: translate(-50%, -50%); } + +.achv-card__top { + display: flex; align-items: center; justify-content: space-between; +} +.achv-card__brand { display: flex; align-items: center; gap: 10px; } +.achv-card__brand-mark { + width: 36px; height: 36px; + display: grid; place-items: center; + border-radius: 10px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + font-weight: 900; font-size: 18px; + box-shadow: 0 2px 6px rgba(16, 185, 129, 0.45); +} +.achv-card__brand-name { font-size: 13px; font-weight: 900; letter-spacing: 0.18em; } +.achv-card__brand-tag { font-size: 9px; color: rgba(255, 255, 255, 0.75); letter-spacing: 0.04em; } +.achv-card__week { + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.10); + border: 1px solid rgba(255, 255, 255, 0.15); + font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; +} + +.achv-card__hero { text-align: center; } +.achv-card__eyebrow { + font-size: 10px; font-weight: 800; letter-spacing: 0.18em; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 6px; +} +.achv-card__title { + font-size: clamp(20px, 3vw, 28px); + font-weight: 900; + background: linear-gradient(135deg, #ffffff 0%, #FCD34D 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + margin-bottom: 4px; + line-height: 1.15; +} +.achv-card__student { + font-size: 16px; font-weight: 700; + color: rgba(255, 255, 255, 0.95); +} + +.achv-card__stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +.achv-card__stat { + padding: 10px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.10); + border: 1px solid rgba(255, 255, 255, 0.12); + text-align: center; +} +.achv-card__stat--sp { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.35), rgba(5, 150, 105, 0.45)); + border-color: rgba(16, 185, 129, 0.4); +} +.achv-card__stat--rank { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.35), rgba(37, 99, 235, 0.45)); + border-color: rgba(59, 130, 246, 0.4); +} +.achv-card__stat-label { + font-size: 9px; font-weight: 800; + letter-spacing: 0.1em; text-transform: uppercase; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 4px; +} +.achv-card__stat-value { + font-size: 22px; font-weight: 900; + color: #ffffff; line-height: 1.1; +} +.achv-card__stat-foot { + font-size: 8.5px; font-weight: 600; + color: rgba(255, 255, 255, 0.7); + margin-top: 2px; +} + +.achv-card__badges { text-align: center; } +.achv-card__badges-label { + font-size: 9px; font-weight: 800; letter-spacing: 0.18em; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 6px; +} +.achv-card__badges-row { + display: flex; justify-content: center; flex-wrap: wrap; gap: 6px; +} +.achv-card__badge { + display: inline-flex; align-items: center; + padding: 4px 10px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(252, 211, 77, 0.25), rgba(245, 158, 11, 0.35)); + border: 1px solid rgba(252, 211, 77, 0.4); + color: #FCD34D; + font-size: 10px; font-weight: 700; +} + +.achv-card__footer { + display: flex; align-items: center; justify-content: space-between; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.15); +} +.achv-card__footer-mark { + font-size: 11px; font-weight: 900; letter-spacing: 0.18em; +} +.achv-card__footer-text { + font-size: 10px; color: rgba(255, 255, 255, 0.7); + font-style: italic; +} +.achv-card__footer-url { + font-size: 10px; font-weight: 700; + color: rgba(255, 255, 255, 0.85); + letter-spacing: 0.06em; +} + +/* ===== Action area ===== */ +.share-achv__export { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; +} +.share-achv__btn { + appearance: none; + display: inline-flex; align-items: center; justify-content: center; + gap: 6px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 13px; font-weight: 700; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} +.share-achv__btn:active { transform: translateY(1px); } +.share-achv__btn:disabled { opacity: 0.6; cursor: not-allowed; } +.share-achv__btn--primary { + background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%); + color: #ffffff; + border-color: #4F46E5; + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); +} +.share-achv__btn--primary:hover:not(:disabled) { + background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); +} + +.share-achv__share { + border-top: 1px solid #e5e7eb; + padding-top: 14px; +} +.share-achv__share-label { + display: block; + font-size: 10px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: #6b7280; + margin-bottom: 8px; +} +.share-achv__share-grid { + display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; +} +.share-achv__share-btn { + appearance: none; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; + padding: 10px 4px; + border: 1px solid #e5e7eb; + background: #ffffff; + border-radius: 8px; + font-size: 9.5px; font-weight: 700; + color: #1f2937; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} +.share-achv__share-btn:hover { background: #f9fafb; border-color: #d1d5db; } +.share-achv__share-btn:active { transform: translateY(1px); } +.share-achv__share-btn.is-copied { background: #ECFDF5; border-color: #10B981; color: #047857; } +.share-achv__share-ico { + display: grid; place-items: center; + width: 26px; height: 26px; + border-radius: 8px; + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + color: #1f2937; + font-size: 13px; font-weight: 900; + letter-spacing: -0.02em; +} + +@media (max-width: 640px) { + .share-achv__inner { padding: 16px; } + .share-achv__share-grid { grid-template-columns: repeat(3, 1fr); } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 958484d..5c13b7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^4.19.2", - "mongoose": "^8.8.4" + "mongoose": "^8.24.1" } }, "node_modules/@mongodb-js/saslprep": { @@ -647,9 +647,9 @@ } }, "node_modules/mongoose": { - "version": "8.24.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.0.tgz", - "integrity": "sha512-EEZwOibDPZ5uZN3bFapfnRskEbdljAf6sP9ln6u+P4e5IfkOAh6Tqw2g8/Tag++KHOAJ095WXT/c0uqRq4Vckg==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.1.tgz", + "integrity": "sha512-UpHBA0l5kHyKJQFjmBaFYQFo5sgz1DK0TRqDkOyBLYbqiIbKKhIvBpHWBXqeo0rgW4kGI1UhhAw+kTQZoj1BdA==", "license": "MIT", "dependencies": { "bson": "^6.10.4", diff --git a/package.json b/package.json index debd813..01b6df5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,6 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^4.19.2", - "mongoose": "^8.8.4" + "mongoose": "^8.24.1" } } From a2932933684b7fc44e573d395f06c585a9a01eab Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 20:22:41 +0530 Subject: [PATCH 03/31] feat: add Premium Desktop Weekly Leaderboard (full 8-step build) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (server/): - services/weeklyWindow.js β€” Mon 06:00 β†’ Sat 23:59 IST window logic, phase detection, countdown - services/weeklyAggregator.js β€” pull SP transactions within window, rank, per-user summary - routes/weekly.js β€” GET /api/weekly/desktop returns week + leaderboard + user bucket + top10/middle/bottom in one round-trip Frontend (client/src/components/weekly-leaderboard/): - WeeklyLeaderboardDesktop.tsx/css β€” premium shell (sidebar, topbar, 3-col layout, light + dark themes) - WeeklyLeaderboard.tsx β€” center table with search, filters, podium styling, You row highlight - RightRail.tsx β€” six widgets (Progress ring, AI Coach, Goals, Insights, Activity, Motivation) - Top10Popup.tsx β€” celebration with confetti/sparkles/poppers, localStorage dismiss, auto-show hook - RegularUserCard.tsx β€” greeting + 4 metric cards + points-to-top-10 CTA - Bottom50Experience.tsx β€” supportive tone, miss list, AI coach, catch-up checklist + recovery bar - FreshWeekEmpty.tsx β€” πŸš€ empty state when weeklySp === 0 Routing: - Wired via ?view=weekly-desktop URL param so the existing dashboard is untouched - Backend computes 'top10' | 'regular' | 'bottom50' bucket so each experience is gated client-side - Celebration popup auto-shows on Top 10 (once per week via localStorage flag) Build: 763 modules, 61.2 KB CSS / 918 KB JS. --- .../weekly-leaderboard/Bottom50Experience.tsx | 214 +++ .../weekly-leaderboard/FreshWeekEmpty.tsx | 56 + .../weekly-leaderboard/RegularUserCard.tsx | 197 ++ .../weekly-leaderboard/RightRail.tsx | 285 +++ .../weekly-leaderboard/Top10Popup.tsx | 197 ++ .../weekly-leaderboard/WeeklyLeaderboard.tsx | 201 +++ .../WeeklyLeaderboardDesktop.css | 1594 +++++++++++++++++ .../WeeklyLeaderboardDesktop.tsx | 225 +++ client/src/main.jsx | 5 + server/routes/weekly.js | 73 + server/server.js | 3 + server/services/weeklyAggregator.js | 89 + server/services/weeklyWindow.js | 123 ++ 13 files changed, 3262 insertions(+) create mode 100644 client/src/components/weekly-leaderboard/Bottom50Experience.tsx create mode 100644 client/src/components/weekly-leaderboard/FreshWeekEmpty.tsx create mode 100644 client/src/components/weekly-leaderboard/RegularUserCard.tsx create mode 100644 client/src/components/weekly-leaderboard/RightRail.tsx create mode 100644 client/src/components/weekly-leaderboard/Top10Popup.tsx create mode 100644 client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx create mode 100644 client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css create mode 100644 client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx create mode 100644 server/routes/weekly.js create mode 100644 server/services/weeklyAggregator.js create mode 100644 server/services/weeklyWindow.js diff --git a/client/src/components/weekly-leaderboard/Bottom50Experience.tsx b/client/src/components/weekly-leaderboard/Bottom50Experience.tsx new file mode 100644 index 0000000..2113fba --- /dev/null +++ b/client/src/components/weekly-leaderboard/Bottom50Experience.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// Bottom50Experience +// Supportive, encouraging tone. No shame. Calming blue + green +// palette. Shown ABOVE the leaderboard when bucket === 'bottom50'. +// Three blocks: +// 1. πŸ’™ You Can Catch Up! headline + motivational sub +// 2. Why You're Behind β€” only activities actually missed +// 3. AI Coach β€” Know Where You Lack +// 4. Catch-Up Plan β€” 6-item checklist + Recovery Progress Bar +// ============================================================ + +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = target; + if (from === to) return; + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - (1 - p) * (1 - p); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; +} + +const ACTIVITY_CATALOG = [ + { id: 'attendance', label: 'Missed Attendance', icon: 'β—·' }, + { id: 'poll', label: 'Missed Daily Poll', icon: 'β—ˆ' }, + { id: 'learning', label: 'Missed Learning Module', icon: '✎' }, + { id: 'bonus', label: "Didn't Complete Bonus Task", icon: 'β—†' }, + { id: 'challenge', label: 'Missed Weekly Challenge', icon: '⌬' }, + { id: 'community', label: 'Low Community Participation', icon: '☺' } +]; + +const CHECKLIST = [ + { id: 'attend', label: "Attend today's session", sp: 10 }, + { id: 'poll', label: "Complete today's poll", sp: 5 }, + { id: 'learning', label: 'Finish one learning module', sp: 8 }, + { id: 'discuss', label: 'Participate in one discussion', sp: 3 }, + { id: 'bonus', label: 'Complete one bonus activity', sp: 6 }, + { id: 'streak', label: 'Maintain attendance streak', sp: 5 } +]; + +export function Bottom50Experience({ data }) { + const me = data?.me; + const missed = me?.missed || []; + const totalSp = me?.weeklySp ?? 0; + + const missedActivities = useMemo(() => { + const flagged = new Set(missed); + if (totalSp < 20) { + flagged.add('learning'); + flagged.add('bonus'); + flagged.add('challenge'); + flagged.add('community'); + } + return ACTIVITY_CATALOG.filter(a => flagged.has(a.id)); + }, [missed, totalSp]); + + const insights = useMemo(() => { + const out = []; + if (missed.includes('attendance')) { + const sessionsLeft = 2; + out.push(`You missed ${sessionsLeft} session${sessionsLeft > 1 ? 's' : ''} this week β€” that's a quick 10 SP back per session.`); + } else if (missed.length === 0) { + out.push('Attendance looked solid this week. Keep the routine going.'); + } + if (missed.includes('poll') || totalSp < 5) { + out.push('Poll participation was lower than your usual pace β€” polls are quick wins for SP.'); + } + if (totalSp < 20) { + out.push('Learning completion is below your weekly average. One module today would shift the trend.'); + } + if (totalSp < 10) { + out.push('Bonus activities were skipped. Even one is enough to change the slope.'); + } + if (out.length === 0) { + out.push('You are closer than you think β€” keep going.'); + } + return out.slice(0, 4); + }, [missed, totalSp]); + + const [checked, setChecked] = useState(new Set()); + const toggle = (id) => setChecked(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + const completedCount = checked.size; + const totalCount = CHECKLIST.length; + const pct = Math.round((completedCount / totalCount) * 100); + const pctDisplay = useCountUp(pct); + const spEarned = CHECKLIST.filter(c => checked.has(c.id)).reduce((s, c) => s + c.sp, 0); + + if (!me) return null; + + return ( + +
+ +
+

You Can Catch Up!

+
+ Every champion starts somewhere. This week wasn't your best, but next week can be. +
+
+
+ +
+
+ WHY YOU'RE BEHIND + {missedActivities.length} flagged +
+ {missedActivities.length === 0 ? ( +
You didn't miss anything tracked this week. Set your sights on next week.
+ ) : ( +
    + {missedActivities.map((m, i) => ( + + + + {m.label} + + ))} +
+ )} +
+ +
+
+ AI COACH + know where you lack +
+
    + {insights.map((line, i) => ( +
  • + β†’ + {line} +
  • + ))} +
+
+ +
+
+ CATCH-UP PLAN + tap to tick +
+
+ {CHECKLIST.map((it, i) => { + const isChecked = checked.has(it.id); + return ( + toggle(it.id)} + initial={{ opacity: 0, x: -4 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: 0.25, delay: 0.05 + i * 0.04 }} + aria-pressed={isChecked} + > + + {it.label} + +{it.sp} + + ); + })} +
+ +
+
+ Recovery Progress + + {pctDisplay}% ({completedCount}/{totalCount}) + +
+
+ +
+
+ +{spEarned} SP earned + Β· + {totalCount - completedCount} more to recover +
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/FreshWeekEmpty.tsx b/client/src/components/weekly-leaderboard/FreshWeekEmpty.tsx new file mode 100644 index 0000000..b7e2cda --- /dev/null +++ b/client/src/components/weekly-leaderboard/FreshWeekEmpty.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// FreshWeekEmpty +// Shown ABOVE the leaderboard when the user opens the site +// after Monday 06:00 IST with no weekly SP yet β€” they get a +// motivating "fresh start" frame with two stat blocks. +// ============================================================ + +export function FreshWeekEmpty({ data }) { + const week = data?.week?.label || 'This Week'; + const phase = data?.week?.phase || 'live'; + const isCalculating = phase === 'calculating'; + + return ( + + + +
+ +
+
{week}{isCalculating ? ' Β· CALCULATING WINNERS' : ' Β· NEW WEEK'}
+

+ {isCalculating ? 'Calculating Weekly Champions…' : 'A New Weekly Challenge Has Begun!'} +

+

+ Start earning Spurti Points through attendance, polls, learning activities, discussions, and bonus tasks. +

+
+
+ +
+
+
CURRENT RANK
+
Not Ranked Yet
+
waiting for your first activity
+
+
+
WEEKLY POINTS
+
0
+
your first session = +10 SP
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/RegularUserCard.tsx b/client/src/components/weekly-leaderboard/RegularUserCard.tsx new file mode 100644 index 0000000..c1ec519 --- /dev/null +++ b/client/src/components/weekly-leaderboard/RegularUserCard.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// RegularUserCard +// Premium greeting + weekly performance summary for users who +// finish outside the Top 10 AND outside the Bottom 50. +// Shown above the leaderboard on the Weekly Leaderboard page. +// ============================================================ + +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = target; + if (from === to) return; + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - (1 - p) * (1 - p); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; +} + +function greetingForHour() { + const h = new Date().getHours(); + if (h < 12) return 'Good Morning'; + if (h < 17) return 'Good Afternoon'; + return 'Good Evening'; +} + +function rankMovementArrow(delta) { + if (delta == null) return { glyph: 'β€”', dir: 'flat', color: '#94a3b8' }; + if (delta > 0) return { glyph: 'β–²', dir: 'up', color: '#10b981' }; + if (delta < 0) return { glyph: 'β–Ό', dir: 'down', color: '#ef4444' }; + return { glyph: 'β€”', dir: 'flat', color: '#94a3b8' }; +} + +export function RegularUserCard({ data, profile, onViewLeaderboard }) { + const me = data?.me; + const rank = me?.weeklyRank; + const displaySp = useCountUp(me?.weeklySp ?? 0); + const displayTop = useCountUp(Math.max(1, me?.pointsToTop10 ?? 0)); + const cohort = data?.cohortSize || 1; + const pct = rank ? Math.min(100, Math.round(((cohort - rank) / cohort) * 100)) : 0; + + // Rank movement: weekly rank vs. total rank (proxy until we get a real + // previous-week comparison in step 8+). + const weeklyRank = me?.weeklyRank; + const lifetimeRank = Number(profile?.rank || 0); + const movement = lifetimeRank && weeklyRank ? weeklyRank - lifetimeRank : 0; + const arrow = rankMovementArrow(movement); + + // Streak is sourced from the gamification storage where the dashboard + // keeps it. Fall back to 0 so the UI renders gracefully. + const [streak, setStreak] = useState(0); + useEffect(() => { + try { + const emailKey = (profile?.email || '').toLowerCase(); + const raw = localStorage.getItem(`spurti_state_${emailKey}`); + if (raw) { + const parsed = JSON.parse(raw); + setStreak(Math.max(0, Number(parsed?.currentStreak || 0))); + } + } catch {} + }, [profile?.email]); + + if (!me) return null; + + return ( + +
+
+
WELCOME BACK
+

+ {greetingForHour()}, {profile?.name?.split(' ')[0] || 'Student'}! +

+
Here's your Weekly Performance.
+
+
+ +
+
{streak}
+
DAY STREAK
+
+
+
+ +
+ {/* Card 1 β€” Weekly SP */} +
+
WEEKLY SP
+
+ + +{displaySp} + +
+
this week
+
+ + {/* Card 2 β€” Weekly Rank */} +
+
WEEKLY RANK
+
+ #{weeklyRank ?? 'β€”'} +
+
+ of {typeof cohort === 'number' ? cohort.toLocaleString() : cohort} +
+
+ + {/* Card 3 β€” Progress Ring + Rank Movement */} +
+
RANK POSITION
+
+ + + + + + + + + + +
{pct}%
+
+
you are in the top {100 - pct}% of the cohort
+
+ + {/* Card 4 β€” Rank Movement */} +
+
RANK MOVEMENT
+
+ {arrow.glyph} + + {movement === 0 ? 'Holding' : `${Math.abs(movement)} ${movement > 0 ? 'up' : 'down'}`} + +
+
vs. last week
+
+
+ + {/* Motivational progress block */} + {me?.pointsToTop10 > 0 && ( + +
+
+ You're only {displayTop} SP away from entering the Top 10 this week. +
+
+ +
+
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/RightRail.tsx b/client/src/components/weekly-leaderboard/RightRail.tsx new file mode 100644 index 0000000..28f3418 --- /dev/null +++ b/client/src/components/weekly-leaderboard/RightRail.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// ============================================================ +// Right Sidebar Widgets +// Six cards stacked in a single column: +// 1. Weekly Progress β€” ring + XP + rank movement + points-to-next +// 2. AI Coach β€” insight chips + reasons +// 3. Today's Goals β€” checklist with progress +// 4. Weekly Insights β€” 8 stat tiles +// 5. Activity Completion β€” 4 mini progress bars +// 6. Motivation Card β€” rotating quote +// ============================================================ + +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = target; + if (from === to) return; + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - (1 - p) * (1 - p); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; +} + +// ----- 1. Weekly Progress ----- +function WeeklyProgress({ data }) { + const me = data?.me; + const displaySp = useCountUp(me?.weeklySp ?? 0); + const displayTop = useCountUp(Math.max(1, me?.pointsToTop10 ?? 0)); + const rank = me?.weeklyRank; + const cohort = data?.cohortSize || 1; + const pct = rank ? Math.min(100, Math.round(((cohort - rank) / cohort) * 100)) : 0; + const ringR = 36; + const ringC = 2 * Math.PI * ringR; + const ringOffset = ringC * (1 - pct / 100); + + return ( + +
+ WEEKLY PROGRESS +
+
+ + + + + + + + + + +
+
+ +{displaySp} + Weekly SP +
+
+ Rank {rank ? '#' + rank : 'β€”'} of {typeof cohort === 'number' ? cohort.toLocaleString() : cohort} + {me?.pointsToTop10 > 0 && ( + + {displayTop} SP to Top 10 + + )} +
+
+
+
+
+
+ + ); +} + +// ----- 2. AI Coach ----- +function AICoach({ data }) { + const me = data?.me; + const missed = me?.missed || []; + const insights = useMemo(() => { + const list = []; + if (missed.includes('attendance')) list.push('Attendance is the biggest lever this week β€” try to attend the next session to recover.'); + else list.push('Attendance looked solid this week.'); + if (missed.includes('poll')) list.push('Poll participation was lower than your usual pace this week.'); + else if (me?.weeklySp === 0) list.push('No poll submissions yet β€” polls are quick wins for SP.'); + if (me?.weeklyRank && me.weeklyRank > (data?.cohortSize || 100) * 0.5) { + list.push('A single attended session can lift your rank by 30+ spots.'); + } + if (list.length < 2) list.push('Keep stacking small wins β€” consistency beats intensity.'); + return list; + }, [missed, me, data]); + + return ( + +
+ AI COACH + where to focus +
+
+ {missed.includes('attendance') && βœ— Missed Attendance} + {missed.includes('poll') && βœ— Missed Poll} + {!missed.includes('attendance') && !missed.includes('poll') && βœ“ Caught up} +
+
    + {insights.slice(0, 3).map((line, i) => ( +
  • + β†’ + {line} +
  • + ))} +
+
+ ); +} + +// ----- 3. Today's Goals ----- +function TodaysGoals() { + const items = [ + { id: 'attend', label: "Attend today's session", sp: 10 }, + { id: 'poll', label: "Complete today's poll", sp: 5 }, + { id: 'streak', label: 'Maintain attendance streak', sp: 5 } + ]; + return ( + +
+ TODAY'S GOALS + +20 SP available +
+
    + {items.map((it, i) => ( + + + ))} +
+
+ ); +} + +// ----- 4. Weekly Insights ----- +function WeeklyInsights({ data, profile }) { + const stats = useMemo(() => { + const me = data?.me; + const totalXp = Number(profile?.totalSp || 0); + return [ + { label: 'Weekly XP', value: '+' + (me?.weeklySp ?? 0), color: '#10b981' }, + { label: "Today SP", value: 'β€”', color: '#3b82f6' }, + { label: 'Attendance %', value: 'β€”', color: '#8b5cf6' }, + { label: 'Poll %', value: 'β€”', color: '#ec4899' }, + { label: 'Learning %', value: 'β€”', color: '#06b6d4' }, + { label: 'Bonus %', value: 'β€”', color: '#fbbf24' }, + { label: 'Best Rank', value: '#' + (profile?.rank ?? 'β€”'), color: '#0ea5e9' }, + { label: 'Total SP', value: totalXp, color: '#6366f1' } + ]; + }, [data, profile]); + + return ( + +
+ WEEKLY INSIGHTS +
+
+ {stats.map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+
+ ); +} + +// ----- 5. Activity Completion ----- +function ActivityCompletion() { + const rows = [ + { label: 'Attendance', color: '#10b981' }, + { label: 'Polls', color: '#3b82f6' }, + { label: 'Learning', color: '#8b5cf6' }, + { label: 'Bonus', color: '#fbbf24' } + ]; + return ( + +
+ ACTIVITY COMPLETION +
+
+ {rows.map(r => ( +
+
+ {r.label} + 0% +
+
+
+
+
+ ))} +
+ + ); +} + +// ----- 6. Motivation Card ----- +const QUOTES = [ + { text: 'Every champion starts somewhere.', sub: 'Consistency beats intensity.' }, + { text: 'Showing up is half the battle.', sub: 'The other half is staying curious.' }, + { text: 'Small wins, stacked daily.', sub: 'A week of small wins is a big one.' }, + { text: 'Your future self is watching.', sub: 'Do today what you will be proud of.' }, + { text: 'Progress over perfection.', sub: 'You do not have to be the best. Just better than yesterday.' } +]; +function MotivationCard() { + const [idx, setIdx] = useState(0); + useEffect(() => { + const t = setInterval(() => setIdx(i => (i + 1) % QUOTES.length), 9000); + return () => clearInterval(t); + }, []); + const q = QUOTES[idx]; + return ( + +
+ DAILY MOTIVATION + πŸ’‘ +
+ + +
"{q.text}"
+
{q.sub}
+
+
+
+ {QUOTES.map((_, i) => ( + + ))} +
+
+ ); +} + +export function RightRail({ data, profile }) { + return ( +
+ + + + + + +
+ ); +} diff --git a/client/src/components/weekly-leaderboard/Top10Popup.tsx b/client/src/components/weekly-leaderboard/Top10Popup.tsx new file mode 100644 index 0000000..a44e15d --- /dev/null +++ b/client/src/components/weekly-leaderboard/Top10Popup.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// ============================================================ +// Top 10 Celebration Popup +// Premium glass card centered, ~45-50% of viewport width. +// Confetti / sparkles / poppers are SCOPED to the popup (not +// the whole dashboard). Closes via Γ— button or "Continue". +// localStorage flag prevents re-showing within the same week. +// ============================================================ + +// localStorage key β€” keyed by Monday's ISO date so a new week re-enables. +const getDismissKey = (weekLabel) => `wl_top10_dismissed_${weekLabel}`; + +function wasDismissedThisWeek(weekLabel) { + try { return !!localStorage.getItem(getDismissKey(weekLabel)); } catch { return false; } +} +function markDismissedThisWeek(weekLabel) { + try { localStorage.setItem(getDismissKey(weekLabel), '1'); } catch {} +} + +// --- Particle primitives --- +function useRandomParticles(count, opts = {}) { + return useMemo(() => Array.from({ length: count }, (_, i) => ({ + id: i, + left: Math.random() * 100, + delay: Math.random() * (opts.maxDelay ?? 1.0), + duration: 2.4 + Math.random() * 2.2, + rotate: Math.random() * 360, + drift: -20 + Math.random() * 40, + size: 6 + Math.random() * 6, + hue: opts.hues ? opts.hues[i % opts.hues.length] : null + })), [count, opts.maxDelay, opts.hues]); +} + +function ConfettiBits({ count = 28 }) { + const particles = useRandomParticles(count, { + maxDelay: 0.8, + hues: ['#6366f1', '#8b5cf6', '#06b6d4', '#fbbf24', '#10b981', '#ec4899'] + }); + return ( + + ); +} + +function Sparkles({ count = 14 }) { + const particles = useRandomParticles(count, { maxDelay: 2.0 }); + return ( + + ); +} + +function PartyPoppers({ count = 6 }) { + // Two popper bursts at the top corners of the popup. + return ( + + ); +} + +export function Top10Popup({ open, onClose, data, onViewFullLeaderboard }) { + const weekLabel = data?.week?.label; + const top10 = data?.top10 || []; + const me = data?.me; + + return ( + + {open && ( + { if (e.target === e.currentTarget) onClose(); }} + > + + + + + + + +
+ +
WEEKLY CHAMPIONS
+

+ Congratulations! +

+
You are among the Top 10 Weekly Performers!
+
+ +
    + {top10.map((row, i) => ( + + {row.rank} + {row.name} + +{row.weeklySp} + {row.isMe && You} + + ))} +
+ + + + + +
+
+
+ )} +
+ ); +} + +// Hook to auto-show on data load, honoring the dismiss flag. +export function useAutoTop10(data) { + const [open, setOpen] = useState(false); + useEffect(() => { + if (!data) return; + if (data.bucket !== 'top10') return; + const weekLabel = data.week?.label; + if (!weekLabel) return; + if (wasDismissedThisWeek(weekLabel)) return; + // Tiny delay so the dashboard mounts first and the celebration feels intentional. + const t = setTimeout(() => setOpen(true), 700); + return () => clearTimeout(t); + }, [data]); + const close = () => { + const weekLabel = data?.week?.label; + if (weekLabel) markDismissedThisWeek(weekLabel); + setOpen(false); + }; + return { open, setOpen, close }; +} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx new file mode 100644 index 0000000..1023942 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// Weekly Champions β€” Center leaderboard table +// Columns: Rank | Name | Weekly SP | Trend (vs. last week's SP) +// Search + filter (all / top10 / cohort / bottom50) + scroll. +// ============================================================ + +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = target; + if (from === to) return; + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - (1 - p) * (1 - p); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; +} + +function Spark({ sp }) { + // Trend indicator: tiny inline bar chart. Faked (deterministic from name hash) + // until a real previous-week comparison is wired up. + const seed = (sp * 17 + 3) % 7; + const bars = Array.from({ length: 7 }, (_, i) => 4 + ((seed + i * 13) % 11)); + return ( + + ); +} + +function FilterChip({ active, onClick, children, badge }) { + return ( + + ); +} + +export function WeeklyLeaderboard({ data }) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); // all | top10 | cohort | bottom50 + const listRef = useRef(null); + + // The dashboard always sees the full 1323-row cohort. We synthesize a + // unified `rows` array and let the filter narrow the view. + const allRows = useMemo(() => { + if (!data) return []; + // Build from top10 + middle + bottom. Avoid duplication by rank. + const byRank = new Map(); + for (const r of data.top10 || []) byRank.set(r.rank, { ...r }); + for (const r of data.middle || []) byRank.set(r.rank, { ...r }); + for (const r of data.bottom || []) byRank.set(r.rank, { ...r }); + return [...byRank.values()].sort((a, b) => a.rank - b.rank); + }, [data]); + + const filteredRows = useMemo(() => { + if (!allRows.length) return []; + let rows = allRows; + const cohortSize = data?.cohortSize || allRows.length; + if (filter === 'top10') rows = rows.filter(r => r.rank <= 10); + else if (filter === 'bottom50') rows = rows.filter(r => r.rank > cohortSize - 50); + // 'cohort' = students near the user (rank +/- 10) + else if (filter === 'cohort' && data?.me) { + const myRank = data.me.weeklyRank; + rows = rows.filter(r => Math.abs(r.rank - myRank) <= 15); + } + const q = query.trim().toLowerCase(); + if (q) rows = rows.filter(r => r.name.toLowerCase().includes(q)); + return rows; + }, [allRows, filter, query, data]); + + // Computed totals / chip counts + const counts = useMemo(() => ({ + all: allRows.length, + top10: allRows.filter(r => r.rank <= 10).length, + cohort: data?.me ? allRows.filter(r => Math.abs(r.rank - data.me.weeklyRank) <= 15).length : 0, + bottom50: allRows.filter(r => r.rank > (data?.cohortSize || allRows.length) - 50).length + }), [allRows, data]); + + // Count up top SP for visual flair + const topSp = useCountUp(filteredRows[0]?.weeklySp ?? 0); + + if (!data) return null; + + return ( +
+
+
+
WEEKLY CHAMPIONS
+

Top performers this week

+

+ {data.week?.label} Β· {data.cohortSize?.toLocaleString() || 'β€”'} students competing + Β· top SP this view: +{topSp} +

+
+
+
+ Top 10 cutoff + +{data.top10?.[9]?.weeklySp ?? 0} +
+
+ Your rank + {data.me?.weeklyRank ? '#' + data.me.weeklyRank : 'β€”'} +
+
+
+ +
+
+ + setQuery(e.target.value)} + aria-label="Search leaderboard" + /> + {query && ( + + )} +
+
+ setFilter('all')} badge={counts.all}>All + setFilter('top10')} badge={counts.top10}>Top 10 + setFilter('cohort')} badge={counts.cohort}>My Cohort + setFilter('bottom50')} badge={counts.bottom50}>Bottom 50 +
+
+ +
+ + + + + + + + + + + {filteredRows.length === 0 && ( + + )} + {filteredRows.map((r, i) => ( + + + + + + + ))} + +
#StudentWeekly SPTrend
No students match your filters.
+ + {r.rank} + + + + {r.name} + {r.isMe && You} + + +{r.weeklySp} + + +
+
+ +
+ Showing {filteredRows.length} of {allRows.length} + + Live Β· Mon 06:00 β†’ Sat 23:59 IST +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css new file mode 100644 index 0000000..8b1c627 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -0,0 +1,1594 @@ +/* ============================================================ + Weekly Leaderboard β€” Desktop Shell + Light & Dark mode, premium enterprise feel. + Glassmorphism, soft gradients, rounded cards, smooth motion. + Min width 1366px (desktop only). + ============================================================ */ + +:root { + --wl-radius: 14px; + --wl-radius-lg: 20px; + --wl-gap: 16px; + --wl-side-w: 248px; + --wl-top-h: 64px; + --wl-right-w: 320px; + --wl-ease: cubic-bezier(0.22, 1, 0.36, 1); + --wl-trans: 220ms var(--wl-ease); +} + +/* --- Dark (default, premium enterprise) --- */ +.wl-shell--dark, +.wl-shell[data-theme="dark"] { + --bg: #0b1020; + --bg-grid: rgba(255, 255, 255, 0.025); + --surface: rgba(255, 255, 255, 0.04); + --surface-2: rgba(255, 255, 255, 0.06); + --surface-strong: rgba(255, 255, 255, 0.09); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #f1f5f9; + --text-muted: #94a3b8; + --text-dim: #64748b; + --accent: #6366f1; + --accent-2: #8b5cf6; + --accent-3: #06b6d4; + --gold: #fbbf24; + --green: #10b981; + --red: #ef4444; + --shadow-lg: 0 24px 48px rgba(0, 0, 0, 0.45), 0 4px 12px rgba(0, 0, 0, 0.25); + --shadow-card: 0 4px 12px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.18); + --grad-brand: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #06b6d4 100%); + --grad-glass: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); +} + +/* --- Light (clean, white-led) --- */ +.wl-shell--light, +.wl-shell[data-theme="light"] { + --bg: #f6f8fc; + --bg-grid: rgba(15, 23, 42, 0.04); + --surface: #ffffff; + --surface-2: #f8fafc; + --surface-strong: #ffffff; + --border: rgba(15, 23, 42, 0.08); + --border-strong: rgba(15, 23, 42, 0.14); + --text: #0f172a; + --text-muted: #475569; + --text-dim: #64748b; + --accent: #4f46e5; + --accent-2: #7c3aed; + --accent-3: #0891b2; + --gold: #d97706; + --green: #059669; + --red: #dc2626; + --shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12), 0 4px 12px rgba(15, 23, 42, 0.06); + --shadow-card: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + --grad-brand: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #0891b2 100%); + --grad-glass: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.6) 100%); +} + +.wl-shell { + min-width: 1366px; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: var(--wl-side-w) 1fr; +} +.wl-shell::before { + content: ''; + position: fixed; inset: 0; + background-image: + linear-gradient(var(--bg-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px); + background-size: 28px 28px; + -webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 30%, transparent 80%); + mask-image: radial-gradient(ellipse at 50% 30%, black 30%, transparent 80%); + pointer-events: none; + z-index: 0; +} + +/* ===== Sidebar ===== */ +.wl-side { + position: sticky; + top: 0; + height: 100vh; + padding: 18px 14px 16px; + background: var(--grad-glass); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 14px; + z-index: 2; +} +.wl-side__brand { + display: flex; align-items: center; gap: 10px; + padding: 4px 6px; +} +.wl-side__brand-mark { + width: 36px; height: 36px; + display: grid; place-items: center; + border-radius: 10px; + background: var(--grad-brand); + color: #fff; font-weight: 900; font-size: 18px; + box-shadow: 0 6px 14px rgba(99, 102, 241, 0.35); +} +.wl-side__brand-name { font-size: 13px; font-weight: 900; letter-spacing: 0.14em; } +.wl-side__brand-tag { font-size: 9.5px; color: var(--text-dim); letter-spacing: 0.04em; margin-top: 1px; } + +.wl-side__nav { + display: flex; flex-direction: column; gap: 2px; + margin-top: 6px; +} +.wl-side__item { + display: flex; align-items: center; gap: 10px; + width: 100%; + padding: 9px 10px; + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 12px; font-weight: 600; + border-radius: 10px; + cursor: pointer; + text-align: left; + transition: background var(--wl-trans), color var(--wl-trans); +} +.wl-side__item:hover { background: var(--surface); color: var(--text); } +.wl-side__item.is-active { + background: var(--grad-brand); + color: #fff; + box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35); +} +.wl-side__icon { + display: grid; place-items: center; + width: 22px; height: 22px; + border-radius: 6px; + background: var(--surface); + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; +} +.wl-side__item.is-active .wl-side__icon { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} +.wl-side__label { flex: 1; } +.wl-side__badge { + font-size: 9px; font-weight: 800; + padding: 2px 6px; + border-radius: 999px; + background: var(--gold); + color: #1f1500; + letter-spacing: 0.04em; +} +.wl-side__item.is-active .wl-side__badge { background: rgba(255, 255, 255, 0.85); color: #1e1b4b; } + +.wl-side__footer { margin-top: auto; display: grid; gap: 8px; } +.wl-side__theme { + display: flex; align-items: center; gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + border-radius: 10px; + font-size: 11px; font-weight: 600; + cursor: pointer; + transition: background var(--wl-trans), color var(--wl-trans); +} +.wl-side__theme:hover { background: var(--surface-2); color: var(--text); } +.wl-side__copy { font-size: 9.5px; color: var(--text-dim); text-align: center; letter-spacing: 0.04em; } + +/* ===== Main ===== */ +.wl-main { + position: relative; + z-index: 1; + display: flex; flex-direction: column; + min-width: 0; +} + +/* ===== Topbar ===== */ +.wl-top { + position: sticky; top: 0; + height: var(--wl-top-h); + padding: 0 22px; + background: var(--grad-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + gap: 18px; + z-index: 5; +} +.wl-top__left { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } +.wl-top__brand { display: flex; align-items: center; gap: 10px; } +.wl-top__brand-mark { + width: 32px; height: 32px; + display: grid; place-items: center; + border-radius: 8px; + background: linear-gradient(135deg, #312e81 0%, #5b21b6 100%); + color: #fff; font-weight: 900; font-size: 11px; letter-spacing: 0.04em; + border: 1px solid rgba(255, 255, 255, 0.2); +} +.wl-top__brand-name { font-size: 13px; font-weight: 800; } +.wl-top__brand-tag { font-size: 9.5px; color: var(--text-dim); margin-top: 1px; } +.wl-top__divider { width: 1px; height: 28px; background: var(--border); } +.wl-top__week { display: flex; flex-direction: column; gap: 1px; } +.wl-top__week-eyebrow { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; + color: var(--text-dim); +} +.wl-top__week-label { + font-size: 11px; font-weight: 700; color: var(--text); + font-variant-numeric: tabular-nums; +} + +.wl-top__center { flex: 1; max-width: 420px; } +.wl-top__search { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + transition: border-color var(--wl-trans), background var(--wl-trans); +} +.wl-top__search:focus-within { border-color: var(--accent); background: var(--surface-2); } +.wl-top__search-icon { color: var(--text-dim); font-size: 14px; } +.wl-top__search input { + flex: 1; min-width: 0; + border: 0; background: transparent; outline: none; + color: var(--text); font-size: 12px; +} +.wl-top__search input::placeholder { color: var(--text-dim); } + +.wl-top__right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.wl-top__countdown { + display: flex; flex-direction: column; gap: 1px; + padding: 6px 12px; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border); + min-width: 110px; + text-align: right; +} +.wl-top__countdown-label { font-size: 8.5px; font-weight: 800; letter-spacing: 0.1em; color: var(--text-dim); } +.wl-top__countdown-time { + font-size: 13px; font-weight: 800; color: var(--accent); + font-variant-numeric: tabular-nums; letter-spacing: 0.02em; +} +.wl-top__icon-btn { + position: relative; + width: 36px; height: 36px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + border-radius: 10px; + cursor: pointer; + font-size: 14px; + transition: background var(--wl-trans), color var(--wl-trans), transform 0.1s; +} +.wl-top__icon-btn:hover { background: var(--surface-2); color: var(--text); } +.wl-top__icon-btn:active { transform: translateY(1px); } +.wl-top__icon-dot { + position: absolute; top: 8px; right: 8px; + width: 7px; height: 7px; border-radius: 50%; + background: var(--red); + box-shadow: 0 0 0 2px var(--surface); +} +.wl-top__profile { + display: flex; align-items: center; gap: 8px; + padding: 4px 10px 4px 4px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: 999px; + cursor: pointer; +} +.wl-top__avatar { + width: 28px; height: 28px; + display: grid; place-items: center; + border-radius: 50%; + background: var(--grad-brand); + color: #fff; + font-size: 12px; font-weight: 800; +} +.wl-top__profile-text { display: flex; flex-direction: column; line-height: 1.2; } +.wl-top__profile-name { font-size: 11px; font-weight: 700; } +.wl-top__profile-meta { font-size: 9.5px; color: var(--text-dim); } + +/* ===== Body (3-col) ===== */ +.wl-body { + flex: 1; + display: grid; + grid-template-columns: 1fr var(--wl-right-w); + gap: var(--wl-gap); + padding: var(--wl-gap); + align-items: flex-start; + min-height: 0; +} + +.wl-center { + background: var(--grad-glass); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: var(--wl-radius-lg); + padding: 22px; + min-height: calc(100vh - var(--wl-top-h) - var(--wl-gap) * 2); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} + +.wl-right { + display: flex; flex-direction: column; gap: var(--wl-gap); + position: sticky; + top: calc(var(--wl-top-h) + var(--wl-gap)); +} + +/* ===== Loading / Error ===== */ +.wl-center--loading, +.wl-center--error { + display: flex; flex-direction: column; + gap: 12px; + align-items: center; justify-content: center; + text-align: center; +} +.wl-skel { + width: 100%; + height: 18px; + border-radius: 6px; + background: linear-gradient(90deg, var(--surface) 0%, var(--surface-strong) 50%, var(--surface) 100%); + background-size: 200% 100%; + animation: wl-shimmer 1.6s ease-in-out infinite; +} +.wl-skel--title { height: 24px; width: 60%; } +.wl-skel--row { width: 90%; } +@keyframes wl-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.wl-loading-note { + margin-top: 10px; + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.04em; +} +.wl-center--error h2 { margin: 0; font-size: 16px; } +.wl-center--error p { margin: 0; font-size: 12px; color: var(--text-muted); } +.wl-btn { + appearance: none; + display: inline-flex; align-items: center; justify-content: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 12px; font-weight: 700; + cursor: pointer; + transition: transform 0.1s, background var(--wl-trans), border-color var(--wl-trans); +} +.wl-btn--primary { background: var(--grad-brand); color: #fff; } +.wl-btn--primary:hover { transform: translateY(-1px); } + +/* ===== Placeholder (Step 2 only) ===== */ +.wl-placeholder { + width: 100%; + text-align: left; +} +.wl-placeholder__title { + margin: 0 0 4px; + font-size: 24px; + font-weight: 900; + background: var(--grad-brand); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wl-placeholder__sub { + margin: 0 0 16px; + font-size: 12px; + color: var(--text-muted); +} +.wl-placeholder__dump { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + max-height: 360px; + overflow: auto; +} + +/* ============================================================ + Weekly Champions β€” Center leaderboard + ============================================================ */ +.wl-leaderboard { + display: grid; + gap: 14px; +} + +.wl-leaderboard__head { + display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} +.wl-leaderboard__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--accent); + margin-bottom: 4px; +} +.wl-leaderboard__title { + margin: 0 0 4px; + font-size: 22px; font-weight: 900; + letter-spacing: -0.01em; + background: var(--grad-brand); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wl-leaderboard__sub { + margin: 0; + font-size: 12px; + color: var(--text-muted); +} +.wl-leaderboard__sub b { color: var(--text); font-weight: 800; } + +.wl-leaderboard__head-stats { display: flex; gap: 8px; } +.wl-leaderboard__head-stat { + padding: 8px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + min-width: 110px; + text-align: right; +} +.wl-leaderboard__head-stat-label { + display: block; + font-size: 8.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 2px; +} +.wl-leaderboard__head-stat-value { + font-size: 16px; font-weight: 900; color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* Toolbar (search + filters) */ +.wl-leaderboard__toolbar { + display: flex; align-items: center; gap: 10px; + flex-wrap: wrap; +} +.wl-leaderboard__search { + flex: 1; min-width: 220px; + display: flex; align-items: center; gap: 6px; + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: 10px; + transition: border-color var(--wl-trans), background var(--wl-trans); +} +.wl-leaderboard__search:focus-within { border-color: var(--accent); background: var(--surface-2); } +.wl-leaderboard__search-icon { color: var(--text-dim); font-size: 13px; } +.wl-leaderboard__search input { + flex: 1; min-width: 0; + border: 0; background: transparent; outline: none; + font-size: 12px; color: var(--text); +} +.wl-leaderboard__search input::placeholder { color: var(--text-dim); } +.wl-leaderboard__clear { + border: 0; background: transparent; cursor: pointer; + color: var(--text-dim); font-size: 14px; line-height: 1; + padding: 0 4px; +} +.wl-leaderboard__clear:hover { color: var(--text); } + +.wl-leaderboard__filters { + display: flex; gap: 6px; +} +.wl-filter { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-size: 11px; font-weight: 700; + border-radius: 999px; + cursor: pointer; + transition: background var(--wl-trans), color var(--wl-trans), border-color var(--wl-trans); +} +.wl-filter:hover { background: var(--surface-2); color: var(--text); } +.wl-filter.is-active { + background: var(--grad-brand); + color: #fff; + border-color: transparent; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} +.wl-filter__badge { + font-size: 9px; + padding: 1px 6px; + border-radius: 999px; + background: var(--surface-strong); + color: var(--text); + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.wl-filter.is-active .wl-filter__badge { background: rgba(255, 255, 255, 0.25); color: #fff; } + +/* Table */ +.wl-leaderboard__table-wrap { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + overflow: hidden; + max-height: 540px; + overflow-y: auto; +} +.wl-leaderboard__table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 12px; +} +.wl-leaderboard__table thead th { + position: sticky; top: 0; + text-align: left; + font-size: 9.5px; font-weight: 800; + letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-dim); + padding: 10px 14px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + z-index: 1; +} +.wl-leaderboard__th-rank { width: 60px; } +.wl-leaderboard__th-sp { width: 110px; text-align: right !important; } +.wl-leaderboard__th-trend { width: 80px; } + +.wl-leaderboard__row { + transition: background var(--wl-trans), transform 0.1s; +} +.wl-leaderboard__row:hover { background: var(--surface-2); } +.wl-leaderboard__row td { + padding: 9px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.wl-leaderboard__row:last-child td { border-bottom: 0; } + +.wl-leaderboard__rank-chip { + display: inline-grid; place-items: center; + min-width: 30px; height: 26px; padding: 0 8px; + border-radius: 999px; + background: var(--surface-strong); + color: var(--text); + font-size: 11px; font-weight: 800; + font-variant-numeric: tabular-nums; +} +.wl-leaderboard__rank-chip--p1 { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #1f1500; + box-shadow: 0 4px 10px rgba(251, 191, 36, 0.3); +} +.wl-leaderboard__rank-chip--p2 { + background: linear-gradient(135deg, #e5e7eb 0%, #9ca3af 100%); + color: #1f2937; + box-shadow: 0 3px 8px rgba(156, 163, 175, 0.3); +} +.wl-leaderboard__rank-chip--p3 { + background: linear-gradient(135deg, #fb923c 0%, #c2410c 100%); + color: #fff; + box-shadow: 0 3px 8px rgba(251, 146, 60, 0.3); +} + +.wl-leaderboard__name { + display: flex; align-items: center; gap: 10px; + font-weight: 600; +} +.wl-leaderboard__avatar { + width: 28px; height: 28px; + display: grid; place-items: center; + border-radius: 50%; + background: var(--grad-brand); + color: #fff; + font-size: 11px; font-weight: 800; + flex-shrink: 0; +} +.wl-leaderboard__name-text { color: var(--text); } +.wl-leaderboard__you { + font-size: 9px; font-weight: 800; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: #fff; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.wl-leaderboard__row.is-me { + background: linear-gradient(90deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06)); + position: relative; +} +.wl-leaderboard__row.is-me::before { + content: ''; + position: absolute; left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--grad-brand); + box-shadow: 0 0 8px rgba(99, 102, 241, 0.5); +} +.wl-leaderboard__row.is-me td { background: transparent; } +.wl-leaderboard__row.is-podium td:first-child { padding-left: 16px; } + +.wl-leaderboard__sp { text-align: right; } +.wl-leaderboard__sp-val { + font-size: 14px; font-weight: 900; + color: var(--green); + font-variant-numeric: tabular-nums; +} +.wl-spark rect { + fill: var(--text-dim); + opacity: 0.7; +} +.wl-leaderboard__row.is-me .wl-spark rect { fill: var(--accent); opacity: 1; } +.wl-leaderboard__empty { + text-align: center; + padding: 40px 20px; + color: var(--text-dim); + font-size: 12px; + font-style: italic; +} + +.wl-leaderboard__foot { + display: flex; align-items: center; gap: 12px; + padding: 6px 4px 0; + font-size: 10px; font-weight: 600; + color: var(--text-dim); + letter-spacing: 0.04em; +} +.wl-leaderboard__foot-spacer { flex: 1; } + +/* ============================================================ + Right Sidebar β€” Six widgets + ============================================================ */ +.wl-right-rail { + display: flex; flex-direction: column; gap: 12px; +} + +.wl-card { + background: var(--grad-glass); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px 14px; + box-shadow: var(--shadow-card); + display: grid; gap: 10px; +} +.wl-card__head { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; +} +.wl-card__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-card__eyebrow--accent { + color: var(--accent); + letter-spacing: 0.04em; + text-transform: none; + font-size: 11px; +} +.wl-card__eyebrow--soft { + color: var(--green); + letter-spacing: 0.04em; + text-transform: none; + font-size: 11px; +} + +/* ===== Weekly Progress ===== */ +.wl-progress { + display: flex; align-items: center; gap: 12px; +} +.wl-progress__ring { flex-shrink: 0; } +.wl-progress__body { display: grid; gap: 6px; min-width: 0; } +.wl-progress__sp { display: flex; flex-direction: column; } +.wl-progress__sp-val { + font-size: 26px; font-weight: 900; + background: var(--grad-brand); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-variant-numeric: tabular-nums; + line-height: 1; +} +.wl-progress__sp-label { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-progress__meta { + display: flex; flex-direction: column; gap: 2px; + font-size: 11px; color: var(--text-muted); +} +.wl-progress__meta b { color: var(--text); font-weight: 800; font-variant-numeric: tabular-nums; } +.wl-progress__top10 { + font-size: 10.5px; + color: var(--accent); +} +.wl-progress__top10 b { color: var(--accent); font-weight: 900; } +.wl-progress__xp { + height: 4px; + background: var(--surface); + border-radius: 999px; + overflow: hidden; +} +.wl-progress__xp-bar { + height: 100%; + background: linear-gradient(90deg, #6366f1, #06b6d4); + border-radius: inherit; + transition: width 0.8s var(--wl-ease); +} + +/* ===== AI Coach ===== */ +.wl-card--coach { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%); +} +.wl-coach__chips { + display: flex; flex-wrap: wrap; gap: 6px; +} +.wl-coach__chip { + font-size: 10px; font-weight: 700; + padding: 4px 8px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); +} +.wl-coach__chip--miss { color: #f59e0b; border-color: rgba(245, 158, 11, 0.25); } +.wl-coach__chip--ok { color: var(--green); border-color: rgba(16, 185, 129, 0.25); } +.wl-coach__list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 6px; +} +.wl-coach__list li { + display: flex; align-items: flex-start; gap: 8px; + font-size: 11px; line-height: 1.45; + color: var(--text); +} +.wl-coach__bullet { color: var(--accent); font-weight: 800; flex-shrink: 0; } + +/* ===== Today's Goals ===== */ +.wl-goals { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.wl-goals li { display: flex; align-items: center; gap: 10px; font-size: 11px; padding: 6px 8px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); } +.wl-goals__box { + width: 14px; height: 14px; flex-shrink: 0; + border-radius: 4px; + border: 1.5px solid var(--border-strong); + background: transparent; +} +.wl-goals__label { flex: 1; color: var(--text); } +.wl-goals__sp { + font-size: 10px; font-weight: 800; + padding: 2px 6px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.15); + color: var(--green); + font-variant-numeric: tabular-nums; +} + +/* ===== Weekly Insights ===== */ +.wl-insights { + display: grid; grid-template-columns: 1fr 1fr; gap: 6px; +} +.wl-insight { + padding: 8px 10px; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border); +} +.wl-insight__label { + font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; +} +.wl-insight__value { + font-size: 15px; font-weight: 900; font-variant-numeric: tabular-nums; +} + +/* ===== Activity Completion ===== */ +.wl-activity { display: flex; flex-direction: column; gap: 8px; } +.wl-activity__row { display: grid; gap: 4px; } +.wl-activity__top { display: flex; justify-content: space-between; align-items: baseline; font-size: 10px; } +.wl-activity__label { color: var(--text-muted); font-weight: 600; } +.wl-activity__pct { color: var(--text); font-weight: 800; font-variant-numeric: tabular-nums; } +.wl-activity__bar { height: 6px; border-radius: 999px; background: var(--surface); overflow: hidden; } +.wl-activity__fill { height: 100%; border-radius: inherit; transition: width 0.6s var(--wl-ease); } + +/* ===== Motivation ===== */ +.wl-card--motivation { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.06) 0%, rgba(245, 158, 11, 0.04) 100%); +} +.wl-motivation { display: grid; gap: 6px; padding: 4px 0; } +.wl-motivation__text { + font-size: 13px; font-weight: 700; line-height: 1.45; + color: var(--text); + font-style: italic; +} +.wl-motivation__sub { + font-size: 11px; color: var(--text-muted); + font-style: normal; +} +.wl-motivation__dots { display: flex; gap: 4px; } +.wl-motivation__dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--border-strong); + transition: background 0.3s, transform 0.3s; +} +.wl-motivation__dot.is-active { background: var(--gold); transform: scale(1.2); } + +/* ============================================================ + TOP-10 Celebration Popup + Centered glass card, ~45-50% of viewport width. + Particles (confetti / sparkles / poppers) are SCOPED to the popup. + ============================================================ */ +.wl-t10-overlay { + position: fixed; inset: 0; z-index: 1200; + background: rgba(8, 12, 26, 0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: grid; place-items: center; + padding: 24px; + overflow: hidden; +} +.wl-t10 { + position: relative; + width: min(560px, 48vw); + max-width: 640px; + background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.4) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.35) 0%, transparent 55%), + linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4c1d95 100%); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 24px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + padding: 0; + overflow: hidden; + isolation: isolate; + color: #fff; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +.wl-t10::before { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, transparent 30%); + pointer-events: none; + border-radius: inherit; + z-index: 0; +} +.wl-t10__inner { + position: relative; z-index: 2; + padding: 28px 30px 22px; + display: grid; gap: 18px; +} +.wl-t10__close { + position: absolute; top: 12px; right: 14px; + width: 32px; height: 32px; + border: 0; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.85); + font-size: 20px; line-height: 1; + border-radius: 50%; + cursor: pointer; + z-index: 5; + transition: background 0.15s, color 0.15s, transform 0.1s; +} +.wl-t10__close:hover { background: rgba(255, 255, 255, 0.2); color: #fff; } +.wl-t10__close:active { transform: scale(0.94); } + +.wl-t10__header { text-align: center; } +.wl-t10__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; + color: rgba(255, 255, 255, 0.75); + margin-bottom: 8px; +} +.wl-t10__title { + margin: 0 0 6px; + font-size: 28px; font-weight: 900; + line-height: 1.15; + letter-spacing: -0.01em; + background: linear-gradient(135deg, #ffffff 0%, #FCD34D 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wl-t10__title-emoji { + display: inline-block; + font-size: 30px; + margin-right: 4px; + -webkit-text-fill-color: initial; + color: initial; +} +.wl-t10__sub { + font-size: 13px; + color: rgba(255, 255, 255, 0.85); + font-weight: 600; +} + +.wl-t10__list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 4px; +} +.wl-t10__row { + position: relative; + display: grid; + grid-template-columns: 32px 1fr auto auto; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.wl-t10__row.is-me { + background: linear-gradient(90deg, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.2) 100%); + border: 1px solid rgba(129, 140, 248, 0.6); + box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.4), 0 0 24px rgba(99, 102, 241, 0.45); + animation: wl-t10-me-pulse 2.4s ease-in-out infinite; +} +@keyframes wl-t10-me-pulse { + 0%, 100% { box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.4), 0 0 24px rgba(99, 102, 241, 0.4); } + 50% { box-shadow: 0 0 0 2px rgba(167, 139, 250, 0.7), 0 0 32px rgba(139, 92, 246, 0.6); } +} +.wl-t10__rank { + width: 28px; height: 28px; + display: grid; place-items: center; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.95); + font-size: 12px; font-weight: 800; + font-variant-numeric: tabular-nums; +} +.wl-t10__row.is-me .wl-t10__rank { background: rgba(255, 255, 255, 0.25); color: #fff; } +.wl-t10__name { + font-size: 13px; font-weight: 700; + color: rgba(255, 255, 255, 0.95); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.wl-t10__sp { + font-size: 13px; font-weight: 900; + color: #FCD34D; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.wl-t10__you { + font-size: 9px; font-weight: 800; + padding: 3px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.95); + color: #4c1d95; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wl-t10__actions { + display: flex; gap: 10px; justify-content: center; + padding-top: 4px; +} +.wl-t10__btn { + appearance: none; + padding: 10px 18px; + border-radius: 10px; + font-size: 12px; font-weight: 800; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} +.wl-t10__btn:active { transform: translateY(1px); } +.wl-t10__btn--ghost { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.18); +} +.wl-t10__btn--ghost:hover { background: rgba(255, 255, 255, 0.18); } +.wl-t10__btn--primary { + background: linear-gradient(135deg, #FCD34D 0%, #fbbf24 100%); + color: #4c1d95; + border-color: #fbbf24; + box-shadow: 0 6px 16px rgba(251, 191, 36, 0.35); +} +.wl-t10__btn--primary:hover { filter: brightness(1.06); } + +/* ===== Particles (scoped to popup) ===== */ +.wl-t10__confetti { + position: absolute; inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 1; +} +.wl-t10__confetto { + position: absolute; + top: -10px; + border-radius: 2px; + opacity: 0.85; + animation: wl-t10-fall linear infinite; +} +@keyframes wl-t10-fall { + 0% { transform: translate(0, -10px) rotate(0deg); opacity: 0; } + 10% { opacity: 0.9; } + 100% { transform: translate(var(--drift, 0), 110vh) rotate(720deg); opacity: 0; } +} + +.wl-t10__sparkles { + position: absolute; inset: 0; + pointer-events: none; + z-index: 1; +} +.wl-t10__sparkle { + position: absolute; + width: 6px; height: 6px; + border-radius: 50%; + background: radial-gradient(circle, #fff 0%, rgba(252, 211, 77, 0.6) 50%, transparent 70%); + box-shadow: 0 0 8px rgba(255, 255, 255, 0.6); +} + +.wl-t10__poppers { + position: absolute; + top: 0; left: 0; right: 0; + height: 220px; + pointer-events: none; + z-index: 1; +} +.wl-t10__popper { + position: absolute; + top: 10px; + width: 80px; height: 120px; +} +.wl-t10__popper--left { left: 8px; } +.wl-t10__popper--right { right: 8px; } +.wl-t10__popper-stream { + position: absolute; + width: 4px; height: 80px; + border-radius: 2px; + top: 30px; left: 38px; + transform-origin: top center; + animation: wl-t10-stream 0.9s ease-out forwards; +} +.wl-t10__popper-stream { + background: linear-gradient(180deg, rgba(99, 102, 241, 0.95) 0%, rgba(139, 92, 246, 0.3) 100%); +} +.wl-t10__popper-stream--alt { + background: linear-gradient(180deg, rgba(252, 211, 77, 0.95) 0%, rgba(245, 158, 11, 0.3) 100%); + transform: rotate(15deg); +} +.wl-t10__popper-stream--side { + background: linear-gradient(180deg, rgba(6, 182, 212, 0.9) 0%, rgba(6, 182, 212, 0.2) 100%); + transform: rotate(-15deg); +} +@keyframes wl-t10-stream { + 0% { transform: scaleY(0); opacity: 0; } + 100% { transform: scaleY(1); opacity: 1; } +} + +/* ============================================================ + Regular User Experience Card + Greeting + 4 metric cards + progress CTA. + ============================================================ */ +.wl-regular { + display: grid; gap: 14px; + padding: 18px 20px; + margin-bottom: 18px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 50%, rgba(6, 182, 212, 0.03) 100%); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} +.wl-regular::before { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 40%); + pointer-events: none; + border-radius: inherit; +} + +.wl-regular__top { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 14px; +} +.wl-regular__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent); + margin-bottom: 6px; +} +.wl-regular__greeting { + margin: 0 0 4px; + font-size: 22px; font-weight: 900; + color: var(--text); + letter-spacing: -0.01em; +} +.wl-regular__name { + background: var(--grad-brand); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wl-regular__sub { + font-size: 12px; color: var(--text-muted); +} + +.wl-regular__streak { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(251, 146, 60, 0.15) 0%, rgba(245, 158, 11, 0.1) 100%); + border: 1px solid rgba(251, 146, 60, 0.25); + flex-shrink: 0; +} +.wl-regular__streak-flame { + font-size: 24px; + filter: drop-shadow(0 4px 12px rgba(251, 146, 60, 0.4)); +} +.wl-regular__streak-body { display: flex; flex-direction: column; line-height: 1; } +.wl-regular__streak-val { + font-size: 24px; font-weight: 900; + color: var(--gold); + font-variant-numeric: tabular-nums; +} +.wl-regular__streak-label { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; + color: var(--text-muted); + margin-top: 2px; +} + +.wl-regular__cards { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; +} +.wl-regular__card { + position: relative; + padding: 14px 14px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + display: grid; gap: 6px; + min-height: 92px; +} +.wl-regular__card--sp { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(5, 150, 105, 0.06)); + border-color: rgba(16, 185, 129, 0.25); +} +.wl-regular__card--rank { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(37, 99, 235, 0.06)); + border-color: rgba(59, 130, 246, 0.25); +} +.wl-regular__card-label { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-regular__card-value { + display: flex; align-items: baseline; gap: 4px; + min-height: 32px; +} +.wl-regular__card-num { + font-size: 26px; font-weight: 900; + font-variant-numeric: tabular-nums; + line-height: 1; +} +.wl-regular__card--sp .wl-regular__card-num { color: var(--green); } +.wl-regular__card--rank .wl-regular__card-num { color: #60a5fa; } +.wl-regular__card-foot { + font-size: 9.5px; color: var(--text-dim); + font-weight: 500; +} + +/* Ring card */ +.wl-regular__ring-wrap { + position: relative; + display: flex; align-items: center; justify-content: center; + width: 64px; height: 64px; +} +.wl-regular__ring-val { + position: absolute; inset: 0; + display: grid; place-items: center; + font-size: 14px; font-weight: 900; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* Movement card */ +.wl-regular__move { + display: flex; align-items: baseline; gap: 6px; + min-height: 32px; +} +.wl-regular__move-arrow { font-size: 18px; font-weight: 900; line-height: 1; } +.wl-regular__move-val { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; +} + +/* CTA block */ +.wl-regular__cta { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); +} +.wl-regular__cta-top { display: grid; gap: 6px; flex: 1; min-width: 0; } +.wl-regular__cta-text { + font-size: 13px; color: var(--text); +} +.wl-regular__cta-text b { color: var(--accent); font-weight: 900; font-variant-numeric: tabular-nums; } +.wl-regular__cta-bar { + height: 6px; border-radius: 999px; + background: var(--surface-strong); + overflow: hidden; +} +.wl-regular__cta-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1, #8b5cf6); + border-radius: inherit; +} +.wl-regular__btn { + appearance: none; + display: inline-flex; align-items: center; justify-content: center; + padding: 10px 18px; + border-radius: 10px; + font-size: 12px; font-weight: 800; + cursor: pointer; + border: 1px solid transparent; + white-space: nowrap; + transition: background 0.15s, transform 0.1s; +} +.wl-regular__btn:active { transform: translateY(1px); } +.wl-regular__btn--primary { + background: var(--grad-brand); + color: #fff; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); +} +.wl-regular__btn--primary:hover { filter: brightness(1.08); } + +@media (max-width: 1180px) { + .wl-regular__cards { grid-template-columns: repeat(2, 1fr); } +} + +/* ============================================================ + Bottom-50 Experience β€” Supportive, calming blue + green + ============================================================ */ +.wl-bottom { + display: grid; gap: 14px; + padding: 18px 20px; + margin-bottom: 18px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.08) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.06) 0%, transparent 40%), + linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} +.wl-bottom::before { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 40%); + pointer-events: none; + border-radius: inherit; +} + +.wl-bottom__hero { + display: flex; align-items: center; gap: 16px; + padding: 4px 0; +} +.wl-bottom__hero-icon { + font-size: 32px; + flex-shrink: 0; + filter: drop-shadow(0 4px 14px rgba(56, 189, 248, 0.3)); +} +.wl-bottom__hero-body { display: grid; gap: 4px; min-width: 0; } +.wl-bottom__hero-title { + margin: 0; + font-size: 22px; font-weight: 900; + letter-spacing: -0.01em; + background: linear-gradient(135deg, #38bdf8 0%, #10b981 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wl-bottom__hero-sub { + font-size: 12.5px; + color: var(--text-muted); + line-height: 1.5; +} + +.wl-bottom__section { + display: grid; gap: 8px; + padding: 12px 14px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); +} +.wl-bottom__section-head { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; +} +.wl-bottom__section-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-bottom__section-eyebrow--accent { + color: var(--accent); +} +.wl-bottom__section-sub { + font-size: 10px; font-weight: 600; color: var(--text-muted); + font-style: italic; +} +.wl-bottom__section-count { + font-size: 9.5px; font-weight: 800; + padding: 2px 8px; + border-radius: 999px; + background: rgba(245, 158, 11, 0.12); + color: #fbbf24; + letter-spacing: 0.04em; +} + +/* Missed-activity list */ +.wl-bottom__list { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: 1fr 1fr; gap: 6px; +} +.wl-bottom__miss { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(245, 158, 11, 0.06); + border: 1px solid rgba(245, 158, 11, 0.18); + font-size: 11px; + font-weight: 600; + color: var(--text); +} +.wl-bottom__miss-icon { + width: 22px; height: 22px; + display: grid; place-items: center; + border-radius: 6px; + background: var(--surface); + font-size: 11px; + color: #fbbf24; + flex-shrink: 0; +} +.wl-bottom__miss-x { + font-size: 14px; font-weight: 900; + color: #f59e0b; + flex-shrink: 0; + width: 14px; height: 14px; + display: grid; place-items: center; + border-radius: 50%; + background: rgba(245, 158, 11, 0.18); +} +.wl-bottom__miss-label { color: var(--text); } +.wl-bottom__clean { + font-size: 11.5px; + color: var(--text-muted); + padding: 6px 0; +} + +/* AI Coach bullets */ +.wl-bottom__coach { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 6px; +} +.wl-bottom__coach li { + display: flex; align-items: flex-start; gap: 8px; + font-size: 11.5px; line-height: 1.5; + color: var(--text); +} +.wl-bottom__coach-bullet { color: #38bdf8; font-weight: 800; flex-shrink: 0; } + +/* Catch-Up Plan */ +.wl-bottom__plan { + background: linear-gradient(180deg, rgba(56, 189, 248, 0.04) 0%, rgba(16, 185, 129, 0.04) 100%); + border-color: rgba(56, 189, 248, 0.2); +} +.wl-bottom__plan-rows { + display: grid; grid-template-columns: 1fr 1fr; gap: 6px; +} +.wl-bottom__check { + appearance: none; + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: 10px; + font-size: 11px; + color: var(--text); + cursor: pointer; + text-align: left; + font-weight: 600; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} +.wl-bottom__check:hover { background: var(--surface-2); } +.wl-bottom__check:active { transform: translateY(1px); } +.wl-bottom__check.is-checked { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.08)); + border-color: rgba(16, 185, 129, 0.4); +} +.wl-bottom__check-box { + width: 18px; height: 18px; + display: grid; place-items: center; + border-radius: 5px; + border: 1.5px solid var(--border-strong); + background: transparent; + color: transparent; + font-size: 11px; font-weight: 900; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.wl-bottom__check.is-checked .wl-bottom__check-box { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: #fff; +} +.wl-bottom__check-label { flex: 1; min-width: 0; } +.wl-bottom__check-sp { + font-size: 10px; font-weight: 800; + padding: 2px 6px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.18); + color: var(--green); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +/* Recovery progress */ +.wl-bottom__recovery { + display: grid; gap: 6px; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + margin-top: 4px; +} +.wl-bottom__recovery-top { + display: flex; align-items: baseline; justify-content: space-between; + font-size: 11px; +} +.wl-bottom__recovery-label { + font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-bottom__recovery-val { font-weight: 700; color: var(--text); } +.wl-bottom__recovery-val b { color: var(--green); font-size: 16px; font-weight: 900; font-variant-numeric: tabular-nums; } +.wl-bottom__recovery-count { font-size: 10px; color: var(--text-dim); font-weight: 600; } +.wl-bottom__recovery-bar { + height: 8px; border-radius: 999px; + background: var(--surface-strong); + overflow: hidden; +} +.wl-bottom__recovery-fill { + height: 100%; + background: linear-gradient(90deg, #38bdf8, #10b981); + border-radius: inherit; + box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); +} +.wl-bottom__recovery-foot { + display: flex; gap: 8px; align-items: baseline; + font-size: 10.5px; font-weight: 700; + color: var(--text-muted); +} +.wl-bottom__recovery-foot-sep { color: var(--text-dim); } +.wl-bottom__recovery-foot span:first-child { color: var(--green); } + +@media (max-width: 1180px) { + .wl-bottom__list, + .wl-bottom__plan-rows { grid-template-columns: 1fr; } +} + +/* ============================================================ + Fresh Week Empty State + ============================================================ */ +.wl-fresh { + position: relative; + display: grid; gap: 14px; + padding: 22px 22px; + margin-bottom: 18px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(251, 191, 36, 0.12) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.1) 0%, transparent 40%), + linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + overflow: hidden; + isolation: isolate; +} +.wl-fresh__bg { + position: absolute; inset: 0; + pointer-events: none; + z-index: 0; +} +.wl-fresh__bg-blob { + position: absolute; + border-radius: 50%; + filter: blur(60px); + opacity: 0.4; +} +.wl-fresh__bg-blob--1 { + top: -30px; left: -30px; + width: 200px; height: 200px; + background: radial-gradient(circle, rgba(251, 191, 36, 0.5), transparent); +} +.wl-fresh__bg-blob--2 { + bottom: -30px; right: -30px; + width: 220px; height: 220px; + background: radial-gradient(circle, rgba(99, 102, 241, 0.5), transparent); +} +.wl-fresh__bg-blob--3 { + top: 50%; left: 50%; + width: 180px; height: 180px; + background: radial-gradient(circle, rgba(6, 182, 212, 0.4), transparent); + transform: translate(-50%, -50%); +} + +.wl-fresh > *:not(.wl-fresh__bg) { position: relative; z-index: 1; } + +.wl-fresh__hero { + display: flex; align-items: center; gap: 16px; +} +.wl-fresh__rocket { + font-size: 40px; + flex-shrink: 0; + filter: drop-shadow(0 8px 20px rgba(251, 191, 36, 0.45)); + margin-right: 4px; +} +.wl-fresh__hero-body { display: grid; gap: 4px; min-width: 0; } +.wl-fresh__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--gold); + margin-bottom: 4px; +} +.wl-fresh__title { + margin: 0; + font-size: 24px; font-weight: 900; + letter-spacing: -0.01em; + background: linear-gradient(135deg, #fbbf24 0%, #6366f1 60%, #06b6d4 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + line-height: 1.15; +} +.wl-fresh__sub { + margin: 4px 0 0; + font-size: 12.5px; line-height: 1.5; + color: var(--text-muted); +} + +.wl-fresh__stats { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; +} +.wl-fresh__stat { + padding: 14px 16px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + display: grid; gap: 4px; +} +.wl-fresh__stat-label { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-fresh__stat-value { + font-size: 22px; font-weight: 900; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1; +} +.wl-fresh__stat-value--muted { color: var(--text-dim); font-size: 16px; font-weight: 800; } +.wl-fresh__stat-foot { + font-size: 9.5px; font-weight: 500; + color: var(--text-muted); +} diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx new file mode 100644 index 0000000..4bf6915 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -0,0 +1,225 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import './WeeklyLeaderboardDesktop.css'; +import { WeeklyLeaderboard } from './WeeklyLeaderboard'; +import { RightRail } from './RightRail'; +import { Top10Popup, useAutoTop10 } from './Top10Popup'; +import { RegularUserCard } from './RegularUserCard'; +import { Bottom50Experience } from './Bottom50Experience'; +import { FreshWeekEmpty } from './FreshWeekEmpty'; + +// ============================================================ +// Weekly Leaderboard β€” Desktop Shell +// Owns: theme state, data fetch, and the 3-column layout. +// Children (added in subsequent steps) render the experiences +// based on `data.bucket`: +// 'pre-start' β†’ empty state (fresh week) +// 'top10' β†’ top-10 popup experience +// 'regular' β†’ regular performance card +// 'bottom50' β†’ supportive catch-up experience +// ============================================================ + +const API = (typeof window !== 'undefined' && window.location.pathname.startsWith('/spurti') ? '/spurti' : '') + '/api'; + +const SIDEBAR_ITEMS = [ + { key: 'dashboard', label: 'Dashboard', icon: 'β—†', badge: null }, + { key: 'leaderboard', label: 'Weekly Leaderboard', icon: 'β˜…', badge: 'Live', active: true }, + { key: 'progress', label: 'My Progress', icon: '◐', badge: null }, + { key: 'learning', label: 'Learning Activities', icon: '✎', badge: null }, + { key: 'attendance', label: 'Attendance', icon: 'β—·', badge: null }, + { key: 'polls', label: 'Polls', icon: 'β—ˆ', badge: null }, + { key: 'challenges', label: 'Challenges', icon: '⌬', badge: 'New' }, + { key: 'rewards', label: 'Rewards', icon: 'β—†', badge: null }, + { key: 'achievements', label: 'Achievements', icon: '✦', badge: null }, + { key: 'settings', label: 'Settings', icon: 'βš™', badge: null } +]; + +function useCountdown(targetMs) { + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + const remain = Math.max(0, targetMs - now); + const h = Math.floor(remain / 3_600_000); + const m = Math.floor((remain % 3_600_000) / 60_000); + const s = Math.floor((remain % 60_000) / 1000); + const pad = (n) => String(n).padStart(2, '0'); + return { text: `${pad(h)}:${pad(m)}:${pad(s)}`, expired: remain <= 0 }; +} + +function Sidebar({ theme, onThemeToggle }) { + return ( + + ); +} + +function Topbar({ data, theme, onThemeToggle, profile }) { + const countdown = useCountdown(data?.deadline?.ms || 0); + return ( +
+
+
+ +
+
IIT Ropar Β· Internship
+
Spurti Engagement Platform
+
+
+
+
+ CURRENT WEEK + {data?.week?.label || 'β€”'} +
+
+ +
+
+ + +
+
+ +
+
+ {data?.week?.phase === 'calculating' ? 'Results in' : 'Deadline'} + {countdown.text} +
+ + +
+ +
+
{profile?.name || 'Student'}
+
{profile?.email || ''}
+
+
+
+
+ ); +} + +function CenterColumn({ data, loading, error, onRetry, children }) { + if (loading) { + return ( +
+
+
+
+
+
Calculating Weekly Champions…
+
+ ); + } + if (error) { + return ( +
+

⚠ Couldn't load the leaderboard

+

{error}

+ +
+ ); + } + return
{children}
; +} + +function RightColumn({ data, children }) { + return ( + + ); +} + +export function WeeklyLeaderboardDesktop({ email, profile }) { + const [theme, setTheme] = useState('dark'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!email) return; + setLoading(true); + setError(null); + try { + const r = await fetch(`${API}/weekly/desktop?email=${encodeURIComponent(email)}`); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'Failed to load'); + setData(j); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, [email]); + + useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + document.documentElement.dataset.wlTheme = theme; + }, [theme]); + + const t10 = useAutoTop10(data); + + return ( +
+ setTheme(t => t === 'dark' ? 'light' : 'dark')} /> +
+ setTheme(t => t === 'dark' ? 'light' : 'dark')} profile={profile} /> +
+ + {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( + + )} + {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( + {}} /> + )} + {data?.bucket === 'bottom50' && data?.me?.weeklySp > 0 && ( + + )} + + + + + +
+
+ +
+ ); +} diff --git a/client/src/main.jsx b/client/src/main.jsx index 7436109..d60311f 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -8,6 +8,8 @@ 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'; +import { WeeklyLeaderboardDesktop } from './components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx'; +import './components/weekly-leaderboard/WeeklyLeaderboardDesktop.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -73,6 +75,9 @@ function App() { if (loading) { return

Spurti

Loading

; } + if (view === 'student' && profile && new URLSearchParams(window.location.search).get('view') === 'weekly-desktop') { + return ; + } if (view === 'student' && profile) { return ( <> diff --git a/server/routes/weekly.js b/server/routes/weekly.js new file mode 100644 index 0000000..64b147e --- /dev/null +++ b/server/routes/weekly.js @@ -0,0 +1,73 @@ +import express from 'express'; +import { weekContaining, weekPhase, nextDeadline, formatWeekLabel } from '../services/weeklyWindow.js'; +import { aggregateWeek, userWeeklySummary } from '../services/weeklyAggregator.js'; + +const router = express.Router(); + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +// GET /api/weekly/desktop?email=... +// Returns everything the desktop dashboard needs in one round-trip: +// - week metadata (label, phase, deadline) +// - full leaderboard (ranked) +// - current user's weekly summary (rank, sp, pointsToTop10, missed) +// - top 10 (for the celebration popup) +router.get('/desktop', async (req, res) => { + const email = normalizeEmail(req.query.email); + if (!email) return res.status(400).json({ error: 'email required' }); + const week = weekContaining(); + const phase = weekPhase(); + const deadline = nextDeadline(); + const agg = await aggregateWeek(week); + const summary = await userWeeklySummary(email, week); + + const top10 = agg.rows.slice(0, 10).map(r => ({ + rank: r.weeklyRank, + name: r.name, + weeklySp: r.weeklySp, + isMe: r.email === email + })); + + // Mid-table (rank 11..cohortSize - 50) and bottom 50 are returned for the + // various experiences. Clients decide what to render based on rank. + const middle = agg.rows + .filter(r => r.weeklyRank > 10 && r.weeklyRank <= Math.max(10, agg.rows.length - 50)) + .map(r => ({ rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email })); + const bottom = agg.rows.slice(-50).map(r => ({ + rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email + })); + + // Pre-compute current user's bucket so the popup can be routed. + const myRank = summary?.weeklyRank ?? null; + const cohortSize = agg.rows.length; + let bucket = 'pre-start'; + if (myRank == null) bucket = 'unknown'; + else if (myRank <= 10) bucket = 'top10'; + else if (myRank > cohortSize - 50) bucket = 'bottom50'; + else bucket = 'regular'; + + res.json({ + week: { ...week, label: formatWeekLabel(week), phase }, + deadline, + cohortSize, + bucket, + me: summary ? { + email, + name: agg.rows.find(r => r.email === email)?.name, + weeklySp: summary.weeklySp, + weeklyRank: myRank, + totalSp: agg.rows.find(r => r.email === email)?.totalSp ?? 0, + pointsToTop10: summary.pointsToTop10, + top10Cutoff: summary.top10Cutoff, + missed: summary.missed, + categories: summary.categories + } : null, + top10, + middle, + bottom + }); +}); + +export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index 2b1c2f0..1080fc7 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,7 @@ import PollRecord from './models/PollRecord.js'; import SPTransaction from './models/SPTransaction.js'; import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; +import weeklyRouter from './routes/weekly.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -595,6 +596,8 @@ function last24Hours(now) { app.use('/api', api); app.use('/spurti/api', api); +app.use('/api/weekly', weeklyRouter); +app.use('/spurti/api/weekly', weeklyRouter); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist)); diff --git a/server/services/weeklyAggregator.js b/server/services/weeklyAggregator.js new file mode 100644 index 0000000..f3667be --- /dev/null +++ b/server/services/weeklyAggregator.js @@ -0,0 +1,89 @@ +import SPTransaction from '../models/SPTransaction.js'; +import Student from '../models/Student.js'; +import { weekContaining } from './weeklyWindow.js'; + +// ============================================================ +// Weekly Aggregator +// Pulls SP transactions within the current week window and +// aggregates per-student. Server is the source of truth β€” the +// client never computes weekly points itself. +// ============================================================ + +const COHORT_FILTER = { status: { $ne: 'excused' } }; + +// Aggregate SP earned between [startMs, endMs] for every active +// student. Includes zero-SP students so the leaderboard shows +// them in the bottom 50 too. +export async function aggregateWeek(week = weekContaining()) { + const match = { + dateTime: { $gte: new Date(week.startMs), $lte: new Date(week.endMs) } + }; + // Sum appliedDelta per email. + const sums = await SPTransaction.aggregate([ + { $match: match }, + { $group: { _id: '$email', weeklySp: { $sum: '$appliedDelta' } } } + ]); + const map = new Map(sums.map(s => [s._id, s.weeklySp])); + + // Per-category counts (what they missed / completed) for AI Coach insights. + const perCat = await SPTransaction.aggregate([ + { $match: match }, + { $group: { _id: { email: '$email', category: '$category' }, count: { $sum: 1 }, sp: { $sum: '$appliedDelta' } } } + ]); + const catMap = new Map(); + for (const row of perCat) { + const e = row._id.email; + const c = row._id.category; + if (!catMap.has(e)) catMap.set(e, {}); + catMap.get(e)[c] = { count: row.count, sp: row.sp }; + } + + // Pull the full active student roster. + const students = await Student.find(COHORT_FILTER) + .select('name email totalSp internshipStartDate') + .sort({ name: 1 }) + .lean(); + + const rows = students.map(s => ({ + email: s.email, + name: s.name, + weeklySp: Math.max(0, map.get(s.email) || 0), + categories: catMap.get(s.email) || {}, + totalSp: Number(s.totalSp || 0) + })); + + // Rank: highest weeklySp first. Tie-break: higher totalSp, then name. + rows.sort((a, b) => + b.weeklySp - a.weeklySp + || b.totalSp - a.totalSp + || a.name.localeCompare(b.name) + ); + + rows.forEach((r, i) => { r.weeklyRank = i + 1; }); + + return { week, rows }; +} + +// Lightweight summary for a single user within the current week. +export async function userWeeklySummary(email, week = weekContaining()) { + const result = await aggregateWeek(week); + const me = result.rows.find(r => r.email === email); + if (!me) return null; + + // Derive miss/catch-up signals from category counts. + const cat = me.categories; + const missed = []; + if (!cat.attendance || cat.attendance.count < 2) missed.push('attendance'); + if (!cat.poll || cat.poll.count < 2) missed.push('poll'); + if (me.weeklySp < 20 && (!cat.attendance || cat.attendance.count === 0)) missed.push('attendance-major'); + + return { + weeklySp: me.weeklySp, + weeklyRank: me.weeklyRank, + cohortSize: result.rows.length, + top10Cutoff: result.rows[9]?.weeklySp ?? 0, + pointsToTop10: Math.max(0, (result.rows[9]?.weeklySp ?? 0) - me.weeklySp), + missed, + categories: cat + }; +} \ No newline at end of file diff --git a/server/services/weeklyWindow.js b/server/services/weeklyWindow.js new file mode 100644 index 0000000..8c327af --- /dev/null +++ b/server/services/weeklyWindow.js @@ -0,0 +1,123 @@ +// ============================================================ +// Weekly Window Service +// Defines the weekly competition window used by the desktop +// Weekly Leaderboard: +// Monday 06:00 IST -> Saturday 23:59 IST +// Sunday -> "Calculating Weekly Champions..." +// Sunday 23:59 IST -> next Monday 06:00 IST (still previous week) +// +// All timestamps are normalized to Asia/Kolkata (IST, UTC+5:30) +// since the IIT Ropar internship is India-based. +// ============================================================ + +const IST_OFFSET_MIN = 330; // 5h30m + +// Returns a Date in UTC that represents the given IST wall clock. +function istToUtc(year, month, day, hour = 0, minute = 0) { + return new Date(Date.UTC(year, month - 1, day, hour - 5, minute - 30)); +} + +// Format a Date as an IST date key like "2026-07-21". +function istDateKey(d) { + const shifted = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + const y = shifted.getUTCFullYear(); + const m = String(shifted.getUTCMonth() + 1).padStart(2, '0'); + const day = String(shifted.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// "Now" expressed in IST wall-clock components. +function nowIstParts(d = new Date()) { + const shifted = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + weekday: shifted.getUTCDay(), // 0=Sun ... 6=Sat + hour: shifted.getUTCHours(), + minute: shifted.getUTCMinutes() + }; +} + +// Determine the *competition week* a given moment falls into. +// A week is anchored on Monday 06:00 IST and ends Saturday 23:59 IST. +// Sunday is a "calculating" day that still belongs to the previous week. +export function weekContaining(d = new Date()) { + const parts = nowIstParts(d); + // Use noon IST to avoid edge cases around the 6:00 boundary. + const shifted = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + let weekday = shifted.getUTCDay(); // 0=Sun ... 6=Sat + let day = shifted.getUTCDate(); + let month = shifted.getUTCMonth() + 1; + let year = shifted.getUTCFullYear(); + + // Sunday (0): the visible "results" still belong to last week. + // Saturday after 23:59 IST has technically ended; treat as next week's start at that instant. + if (weekday === 0) { + // Roll back to the Monday of the *previous* week. + day -= 6; + } else if (weekday >= 2) { + // Tue..Sat: anchor on this week's Monday. + day -= (weekday - 1); + } else if (weekday === 1) { + // Monday: depends on time. Before 06:00 β†’ previous week; otherwise this week. + if (parts.hour < 6) day -= 7; + } + // Normalize the rolled-back date. + const mondayUtc = istToUtc(year, month, day, 6, 0); + const saturdayUtc = new Date(mondayUtc.getTime() + (5 * 24 + 17) * 3600 * 1000 + 59 * 60_000); + // The week "label" is the Monday's IST date key. + const label = istDateKey(mondayUtc); + return { + label, // e.g. "2026-07-20" + startIso: mondayUtc.toISOString(), + endIso: saturdayUtc.toISOString(), + startMs: mondayUtc.getTime(), + endMs: saturdayUtc.getTime() + }; +} + +// What "phase" are we in for a given moment? +// 'pre-start' β€” Monday before 06:00 (rare β€” usually first launch) +// 'live' β€” Mon 06:00 β†’ Sat 23:59 +// 'calculating'β€” Sunday (results are being finalized) +export function weekPhase(d = new Date()) { + const shifted = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + const weekday = shifted.getUTCDay(); // 0..6 + const hour = shifted.getUTCHours(); + const minute = shifted.getUTCMinutes(); + + if (weekday === 0) return 'calculating'; + if (weekday === 1 && (hour < 6 || (hour === 6 && minute < 0))) return 'pre-start'; + if (weekday === 6 && hour === 23 && minute >= 59) return 'live'; // last minute + return 'live'; +} + +// Countdown to Saturday 23:59 IST (or to next Monday 06:00 if currently calculating). +export function nextDeadline(d = new Date()) { + const w = weekContaining(d); + const phase = weekPhase(d); + const shifted = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + const weekday = shifted.getUTCDay(); + const year = shifted.getUTCFullYear(); + const month = shifted.getUTCMonth() + 1; + const day = shifted.getUTCDate(); + if (phase === 'live' || phase === 'pre-start') { + // Saturday of the same week at 23:59 IST. + const daysFromMon = weekday === 0 ? 6 : weekday === 1 ? 5 : 6 - weekday + (weekday === 6 ? 0 : 0); + // Easier: just take endMs from weekContaining. + return { ms: w.endMs, iso: w.endIso, phase }; + } + // calculating β†’ next Monday 06:00 IST + const nextMondayShifted = new Date(Date.UTC(year, month - 1, day + (weekday === 0 ? 1 : 8 - weekday), 6, 0)); + const utc = new Date(nextMondayShifted.getTime() - IST_OFFSET_MIN * 60_000); + return { ms: utc.getTime(), iso: utc.toISOString(), phase }; +} + +// Human-readable week label, e.g. "Week of Jul 20 – Jul 25". +export function formatWeekLabel(week) { + const startIst = new Date(new Date(week.startIso).getTime() + IST_OFFSET_MIN * 60_000); + const endIst = new Date(new Date(week.endIso).getTime() + IST_OFFSET_MIN * 60_000); + const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); + return `${fmt(startIst)} – ${fmt(endIst)}`; +} \ No newline at end of file From 848d27501c2a3c7f374160fe096ea4631109bbdd Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 20:33:28 +0530 Subject: [PATCH 04/31] fix: add preview mode so weekly leaderboard is testable without auth - ?view=weekly-desktop now also fires in preview mode - ?preview=1 bypasses the Samagama auth flow with a real student profile - The injected profile uses a real existing email so the API returns bucket='top10' rank=8, which lights up the Top-10 celebration popup --- client/src/main.jsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index d60311f..8de55f8 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -15,13 +15,31 @@ const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : '' const API = `${APP_BASE}/api`; function App() { - const [view, setView] = useState(() => new URLSearchParams(window.location.search).get('admin') === '1' ? 'admin-login' : 'landing'); - const [profile, setProfile] = useState(null); + // Preview mode (?preview=1) lets anyone land on the dashboard with a fake + // student profile so the weekly leaderboard can be inspected without going + // through the Samagama auth flow. Falls back to the search/confirm flow + // otherwise. + const urlParams = new URLSearchParams(window.location.search); + const isPreviewMode = urlParams.get('preview') === '1' || urlParams.get('view') === 'weekly-desktop'; + const [view, setView] = useState(() => isPreviewMode ? 'student-preview' : (urlParams.get('admin') === '1' ? 'admin-login' : 'landing')); + const [profile, setProfile] = useState(() => isPreviewMode ? { student: { + name: 'A D S ABHISHEK', + email: 'addaduguru.durga2024@vitstudent.ac.in', + totalSp: 580, + rank: 8, + cohortSize: 1323, + level: 4, + trophyLeague: 'Silver I', + legendBadgeUnlocked: false, + leaderboardGroup: 'g1', + leaderboardGroupLabel: 'Group 1', + surveyCompleted: true + }} : null); const [excused, setExcused] = useState(null); const [admin, setAdmin] = useState(null); const [adminAuth, setAdminAuth] = useState(null); const [config, setConfig] = useState({ allowStudentSearch: true }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!isPreviewMode); useEffect(() => { if (!profile?.student) return; @@ -75,7 +93,7 @@ function App() { if (loading) { return

Spurti

Loading

; } - if (view === 'student' && profile && new URLSearchParams(window.location.search).get('view') === 'weekly-desktop') { + if ((view === 'student' || view === 'student-preview') && profile && new URLSearchParams(window.location.search).get('view') === 'weekly-desktop') { return ; } if (view === 'student' && profile) { From 29e4a723c9cc27a804b37512d8e06dc56d93d760 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 20:42:17 +0530 Subject: [PATCH 05/31] fix: integrate Weekly Leaderboard into the existing Spurti dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the standalone ?view=weekly-desktop route + preview mode hack - Added 'Weekly Leaderboard is Live!' entry pill in ReplaySection (third pill next to 'Your Week is Ready!' and 'Your Spurti Journey is Ready!') - Pill opens the desktop shell in a fixed full-screen overlay - Overlay has its own close button + soft blur backdrop - Top-10 celebration popup stacks above the overlay via existing z-index 1200 - Same email flow: existing Samagama auth β†’ click pill β†’ shell loads with the user's real data --- client/src/components/replay/EntryPill.tsx | 17 ++++++++++ client/src/components/replay/replay.css | 34 +++++++++++++++++++- client/src/main.jsx | 37 ++++++++-------------- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/client/src/components/replay/EntryPill.tsx b/client/src/components/replay/EntryPill.tsx index 18a5d99..e8a47f8 100644 --- a/client/src/components/replay/EntryPill.tsx +++ b/client/src/components/replay/EntryPill.tsx @@ -2,6 +2,23 @@ import React from 'react'; import { motion } from 'framer-motion'; export const EntryPill = ({ kind = 'weekly', onClick }) => { + if (kind === 'leaderboard') { + return ( + + + Weekly Leaderboard is Live! + + + ); + } if (kind === 'final') { return ( isPreviewMode ? 'student-preview' : (urlParams.get('admin') === '1' ? 'admin-login' : 'landing')); - const [profile, setProfile] = useState(() => isPreviewMode ? { student: { - name: 'A D S ABHISHEK', - email: 'addaduguru.durga2024@vitstudent.ac.in', - totalSp: 580, - rank: 8, - cohortSize: 1323, - level: 4, - trophyLeague: 'Silver I', - legendBadgeUnlocked: false, - leaderboardGroup: 'g1', - leaderboardGroupLabel: 'Group 1', - surveyCompleted: true - }} : null); + const [view, setView] = useState(() => new URLSearchParams(window.location.search).get('admin') === '1' ? 'admin-login' : 'landing'); + const [profile, setProfile] = useState(null); const [excused, setExcused] = useState(null); const [admin, setAdmin] = useState(null); const [adminAuth, setAdminAuth] = useState(null); const [config, setConfig] = useState({ allowStudentSearch: true }); - const [loading, setLoading] = useState(!isPreviewMode); + const [loading, setLoading] = useState(true); useEffect(() => { if (!profile?.student) return; @@ -93,9 +75,6 @@ function App() { if (loading) { return

Spurti

Loading

; } - if ((view === 'student' || view === 'student-preview') && profile && new URLSearchParams(window.location.search).get('view') === 'weekly-desktop') { - return ; - } if (view === 'student' && profile) { return ( <> @@ -895,6 +874,7 @@ function SurveyModal({ survey, student, onDone }) { function ReplaySection({ profile }) { const [weeklyOpen, setWeeklyOpen] = useState(false); const [finalOpen, setFinalOpen] = useState(false); + const [lbOpen, setLbOpen] = useState(false); const [share, setShare] = useState(null); const [unlocked, setUnlocked] = useState(false); useEffect(() => { @@ -905,6 +885,9 @@ function ReplaySection({ profile }) { <>
setWeeklyOpen(true)} /> + + setLbOpen(true)} /> + {unlocked && ( setFinalOpen(true)} /> @@ -921,6 +904,12 @@ function ReplaySection({ profile }) { onClose={() => setShare(null)} /> )} + {lbOpen && ( +
+ + +
+ )} ); } From f537331470461f9eac22d877475ff0f697753f32 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 20:51:09 +0530 Subject: [PATCH 06/31] fix: render Weekly Leaderboard inline on the same Spurti page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the overlay/modal approach - Removed the leaderboard entry pill (no longer needed) - WeeklyLeaderboardDesktop now takes an 'inline' prop that drops its own sidebar/topbar and renders only the body - Inline mode: transparent shell, scoped to the host card style, no fixed positioning β€” lives as a section between StudentPulse and the Tabs row in StudentView - All four bucket experiences still work (top10 popup, regular card, bottom50 experience, fresh week empty) plus the 6-widget right rail - Top-10 celebration popup stacks correctly above the inline mount --- .../WeeklyLeaderboardDesktop.css | 22 ++++++++ .../WeeklyLeaderboardDesktop.tsx | 52 ++++++++++++------- client/src/main.jsx | 11 +--- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css index 8b1c627..8a3ec43 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -77,6 +77,28 @@ display: grid; grid-template-columns: var(--wl-side-w) 1fr; } + +/* Inline mount inside an existing page β€” skip the min-width & sidebar. */ +.wl-shell-inline { + display: block; + min-width: 0; + min-height: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text); + position: relative; + background: transparent; +} +.wl-shell-inline .wl-body { + grid-template-columns: 1fr var(--wl-right-w); + padding: 0; + margin-top: 12px; +} +.wl-shell-inline .wl-center { + min-height: 0; + background: var(--surface); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.02); +} +.wl-shell-inline .wl-right { position: static; } .wl-shell::before { content: ''; position: fixed; inset: 0; diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 4bf6915..e613aec 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -167,7 +167,7 @@ function RightColumn({ data, children }) { ); } -export function WeeklyLeaderboardDesktop({ email, profile }) { +export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const [theme, setTheme] = useState('dark'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -196,28 +196,44 @@ export function WeeklyLeaderboardDesktop({ email, profile }) { const t10 = useAutoTop10(data); + const body = ( +
+ + {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( + + )} + {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( + {}} /> + )} + {data?.bucket === 'bottom50' && data?.me?.weeklySp > 0 && ( + + )} + + + + + +
+ ); + + if (inline) { + // Render only the body (3-col grid + theme-aware chrome) so the host + // page's own sidebar / topbar remain visible. The full App theme + // already inherits the design tokens. + return ( +
+ {body} + +
+ ); + } + return (
setTheme(t => t === 'dark' ? 'light' : 'dark')} />
setTheme(t => t === 'dark' ? 'light' : 'dark')} profile={profile} /> -
- - {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( - - )} - {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( - {}} /> - )} - {data?.bucket === 'bottom50' && data?.me?.weeklySp > 0 && ( - - )} - - - - - -
+ {body}
diff --git a/client/src/main.jsx b/client/src/main.jsx index 953414a..ebd8e81 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -281,6 +281,7 @@ function StudentView({ profile, onBack }) { + {tab === 'bank' && } {tab === 'polls' && } @@ -874,7 +875,6 @@ function SurveyModal({ survey, student, onDone }) { function ReplaySection({ profile }) { const [weeklyOpen, setWeeklyOpen] = useState(false); const [finalOpen, setFinalOpen] = useState(false); - const [lbOpen, setLbOpen] = useState(false); const [share, setShare] = useState(null); const [unlocked, setUnlocked] = useState(false); useEffect(() => { @@ -885,9 +885,6 @@ function ReplaySection({ profile }) { <>
setWeeklyOpen(true)} /> - - setLbOpen(true)} /> - {unlocked && ( setFinalOpen(true)} /> @@ -904,12 +901,6 @@ function ReplaySection({ profile }) { onClose={() => setShare(null)} /> )} - {lbOpen && ( -
- - -
- )} ); } From b09078f5a246298102ebf0050c5971d90759aab2 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 20:57:16 +0530 Subject: [PATCH 07/31] refactor: remove bottom-50 experience from weekly leaderboard inline view - Drop the Bottom50Experience branch + import from WeeklyLeaderboardDesktop.tsx - The regular + fresh week + top 10 paths still work - Component file stays in the repo in case it's wired elsewhere later --- .../weekly-leaderboard/WeeklyLeaderboardDesktop.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index e613aec..556faa6 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -5,7 +5,6 @@ import { WeeklyLeaderboard } from './WeeklyLeaderboard'; import { RightRail } from './RightRail'; import { Top10Popup, useAutoTop10 } from './Top10Popup'; import { RegularUserCard } from './RegularUserCard'; -import { Bottom50Experience } from './Bottom50Experience'; import { FreshWeekEmpty } from './FreshWeekEmpty'; // ============================================================ @@ -205,9 +204,6 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( {}} /> )} - {data?.bucket === 'bottom50' && data?.me?.weeklySp > 0 && ( - - )} From eac702387bffef45e8f26c43edcf43f8321c1b64 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 21:07:38 +0530 Subject: [PATCH 08/31] feat: replace rank table with weekly performance curve + delete bottom-50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deleted Bottom50Experience.tsx entirely (no longer used anywhere) - Stripped all wl-bottom CSS (Bottom-50 experience styles) - Removed the Bottom-50 filter chip from the leaderboard - Removed cohortSize points-to-bottom logic from aggregation helper - Replaced the scrollable rank table with a 'Weekly performance curve' SVG graph: - X axis = cohort rank (1 β†’ cohortSize) - Y axis = weekly SP - Indigo histogram bars showing the cohort distribution - Top-10 cutoff line (amber pill, dashed) - Each student plotted as a green dot - Current user highlighted with an indigo ring + pulsing animation - Search-by-name highlights a gold ring on the matched student - Hover tooltip on any dot - Legend with cohort / top10 / me / match swatches --- .../weekly-leaderboard/WeeklyLeaderboard.tsx | 417 +++++++++++------- .../WeeklyLeaderboardDesktop.css | 308 ++++--------- 2 files changed, 332 insertions(+), 393 deletions(-) diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx index 1023942..9f3c45b 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -1,71 +1,269 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { motion } from 'framer-motion'; +import React, { useMemo, useRef, useState } from 'react'; // ============================================================ -// Weekly Champions β€” Center leaderboard table -// Columns: Rank | Name | Weekly SP | Trend (vs. last week's SP) -// Search + filter (all / top10 / cohort / bottom50) + scroll. +// Weekly Performance Curve (graph view) +// Replaces the old scrollable rank table. Plots each student's +// Weekly SP as a dot, with the cohort distribution underneath. +// Renders a "Top 10 cutoff" line, the user's marker, and lets +// the user search by name to highlight a specific student. // ============================================================ -function useCountUp(target, duration = 700) { - const [v, setV] = useState(target); - const prev = useRef(target); - useEffect(() => { - const from = prev.current; - const to = target; - if (from === to) return; - const t0 = performance.now(); - let raf; - const tick = (now) => { - const p = Math.min((now - t0) / duration, 1); - const eased = 1 - (1 - p) * (1 - p); - setV(Math.round(from + (to - from) * eased)); - if (p < 1) raf = requestAnimationFrame(tick); - else prev.current = to; - }; - raf = requestAnimationFrame(tick); - return () => raf && cancelAnimationFrame(raf); - }, [target]); - return v; +function fmtNum(n) { + if (n == null || Number.isNaN(n)) return 'β€”'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + return String(n); } -function Spark({ sp }) { - // Trend indicator: tiny inline bar chart. Faked (deterministic from name hash) - // until a real previous-week comparison is wired up. - const seed = (sp * 17 + 3) % 7; - const bars = Array.from({ length: 7 }, (_, i) => 4 + ((seed + i * 13) % 11)); - return ( - +function RankGraph({ rows, cohortSize, myRank, top10Boundary, topRef }) { + const [query, setQuery] = useState(''); + const [hover, setHover] = useState(null); + const W = 720, H = 240; + const padL = 36, padR = 18, padT = 18, padB = 30; + const innerW = W - padL - padR; + const innerH = H - padT - padB; + + // X-axis = rank (1 β†’ cohortSize). Y-axis = weeklySp. + const maxRank = Math.max(cohortSize || 0, rows?.length || 0, 1); + const maxSp = Math.max( + top10Boundary ?? 0, + ...rows.map(r => Number(r.weeklySp) || 0), + 1 ); -} -function FilterChip({ active, onClick, children, badge }) { + const xFor = (rank) => padL + ((rank - 1) / Math.max(maxRank - 1, 1)) * innerW; + const yFor = (sp) => padT + innerH - (sp / maxSp) * innerH; + + // Build histogram buckets of weeklySp across the cohort. + const buckets = useMemo(() => { + const N = 18; + const counts = new Array(N).fill(0); + if (!rows?.length) return counts; + for (const r of rows) { + const sp = Number(r.weeklySp) || 0; + const idx = Math.min(N - 1, Math.max(0, Math.round((sp / maxSp) * (N - 1)))); + counts[idx] += 1; + } + return counts; + }, [rows, maxSp]); + const bucketMax = Math.max(1, ...buckets); + const barW = innerW / buckets.length; + + // Match search against any student + const matched = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return null; + return rows.find(r => (r.name || '').toLowerCase().includes(q)) || null; + }, [rows, query]); + + // Y-axis ticks (0, 25, 50, 75, 100% of maxSp) + const yTicks = [0, 0.25, 0.5, 0.75, 1].map(p => ({ + v: maxSp * p, + y: yFor(maxSp * p) + })); + + // X-axis ticks: top-10 boundary, midpoint, cohort, my rank + const xTicks = [ + { rank: 1, label: '#1' }, + { rank: 10, label: '#10 (Top 10)' }, + { rank: Math.round(maxRank / 2), label: `#${Math.round(maxRank / 2)}` }, + { rank: maxRank, label: `#${maxRank}` } + ]; + if (myRank && myRank > 10 && myRank < maxRank) { + xTicks.push({ rank: myRank, label: `#${myRank} (You)` }); + } + + const top10X = xFor(10); + return ( - +
+
+
+ + setQuery(e.target.value)} + aria-label="Find a student" + /> + {query && ( + + )} + {matched && ( + + {matched.name} Β· #{matched.rank} Β· +{matched.weeklySp} SP + + )} +
+
+ Cohort + Top 10 cutoff + {myRank && You} + {matched && Match} +
+
+ + + + + + + + + + + + + + {/* Y-grid + labels */} + {yTicks.map((t, i) => ( + + + + {fmtNum(Math.round(t.v))} + + + ))} + + {/* X-axis line + labels */} + + {xTicks.map((t, i) => ( + + + + {t.label} + + + ))} + + {/* Cohort histogram bars */} + {buckets.map((c, i) => { + const h = (c / bucketMax) * (innerH * 0.7); + const x = padL + i * barW + 2; + const y = padT + innerH - h; + const w = Math.max(1, barW - 4); + return ( + + ); + })} + + {/* Top 10 cutoff vertical line */} + + + TOP 10 + + {/* All-student dots β€” sample 200 max for perf */} + + {rows.slice(0, 400).map((r) => { + const x = xFor(r.rank); + const y = yFor(Number(r.weeklySp) || 0); + const isMe = r.isMe; + const isMatch = matched && matched.rank === r.rank; + return ( + setHover(r)} + onMouseLeave={() => setHover(null)} + style={{ cursor: 'pointer' }} + /> + ); + })} + + + {/* Me marker */} + {myRank && (() => { + const meRow = rows.find(r => r.isMe); + if (!meRow) return null; + const x = xFor(meRow.rank); + const y = yFor(Number(meRow.weeklySp) || 0); + return ( + + + + + + + + + ); + })()} + + {/* Match marker */} + {matched && (() => { + const x = xFor(matched.rank); + const y = yFor(Number(matched.weeklySp) || 0); + return ( + + + + ); + })()} + + {/* Hover tooltip */} + {hover && ( + + + + {hover.name?.slice(0, 24)} + + + #{hover.rank} Β· +{hover.weeklySp} SP + + + )} + + {/* Y-axis label */} + + WEEKLY SP + + + COHORT RANK + + + +
+ {rows.length.toLocaleString()} students plotted Β· {top10Boundary || 0} SP needed for Top 10 + {myRank && You're at #{myRank}} +
+
); } export function WeeklyLeaderboard({ data }) { - const [query, setQuery] = useState(''); - const [filter, setFilter] = useState('all'); // all | top10 | cohort | bottom50 const listRef = useRef(null); - // The dashboard always sees the full 1323-row cohort. We synthesize a - // unified `rows` array and let the filter narrow the view. const allRows = useMemo(() => { if (!data) return []; - // Build from top10 + middle + bottom. Avoid duplication by rank. const byRank = new Map(); for (const r of data.top10 || []) byRank.set(r.rank, { ...r }); for (const r of data.middle || []) byRank.set(r.rank, { ...r }); @@ -73,129 +271,24 @@ export function WeeklyLeaderboard({ data }) { return [...byRank.values()].sort((a, b) => a.rank - b.rank); }, [data]); - const filteredRows = useMemo(() => { - if (!allRows.length) return []; - let rows = allRows; - const cohortSize = data?.cohortSize || allRows.length; - if (filter === 'top10') rows = rows.filter(r => r.rank <= 10); - else if (filter === 'bottom50') rows = rows.filter(r => r.rank > cohortSize - 50); - // 'cohort' = students near the user (rank +/- 10) - else if (filter === 'cohort' && data?.me) { - const myRank = data.me.weeklyRank; - rows = rows.filter(r => Math.abs(r.rank - myRank) <= 15); - } - const q = query.trim().toLowerCase(); - if (q) rows = rows.filter(r => r.name.toLowerCase().includes(q)); - return rows; - }, [allRows, filter, query, data]); - - // Computed totals / chip counts - const counts = useMemo(() => ({ - all: allRows.length, - top10: allRows.filter(r => r.rank <= 10).length, - cohort: data?.me ? allRows.filter(r => Math.abs(r.rank - data.me.weeklyRank) <= 15).length : 0, - bottom50: allRows.filter(r => r.rank > (data?.cohortSize || allRows.length) - 50).length - }), [allRows, data]); - - // Count up top SP for visual flair - const topSp = useCountUp(filteredRows[0]?.weeklySp ?? 0); - if (!data) return null; + const cohortSize = data.cohortSize || allRows.length || 1; + const myRank = data.me?.weeklyRank; + const top10Boundary = data.top10?.[9]?.weeklySp ?? 0; return (
WEEKLY CHAMPIONS
-

Top performers this week

+

Weekly performance curve

- {data.week?.label} Β· {data.cohortSize?.toLocaleString() || 'β€”'} students competing - Β· top SP this view: +{topSp} + {data.week?.label} Β· {typeof cohortSize === 'number' ? cohortSize.toLocaleString() : cohortSize} students competing

-
-
- Top 10 cutoff - +{data.top10?.[9]?.weeklySp ?? 0} -
-
- Your rank - {data.me?.weeklyRank ? '#' + data.me.weeklyRank : 'β€”'} -
-
-
-
- - setQuery(e.target.value)} - aria-label="Search leaderboard" - /> - {query && ( - - )} -
-
- setFilter('all')} badge={counts.all}>All - setFilter('top10')} badge={counts.top10}>Top 10 - setFilter('cohort')} badge={counts.cohort}>My Cohort - setFilter('bottom50')} badge={counts.bottom50}>Bottom 50 -
-
- -
- - - - - - - - - - - {filteredRows.length === 0 && ( - - )} - {filteredRows.map((r, i) => ( - - - - - - - ))} - -
#StudentWeekly SPTrend
No students match your filters.
- - {r.rank} - - - - {r.name} - {r.isMe && You} - - +{r.weeklySp} - - -
-
- -
- Showing {filteredRows.length} of {allRows.length} - - Live Β· Mon 06:00 β†’ Sat 23:59 IST -
+
); } \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css index 8a3ec43..94f336e 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -1279,237 +1279,6 @@ .wl-regular__cards { grid-template-columns: repeat(2, 1fr); } } -/* ============================================================ - Bottom-50 Experience β€” Supportive, calming blue + green - ============================================================ */ -.wl-bottom { - display: grid; gap: 14px; - padding: 18px 20px; - margin-bottom: 18px; - border-radius: 18px; - background: - radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.08) 0%, transparent 40%), - radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.06) 0%, transparent 40%), - linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%); - border: 1px solid var(--border); - box-shadow: var(--shadow-card); - position: relative; - overflow: hidden; -} -.wl-bottom::before { - content: ''; - position: absolute; inset: 0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 40%); - pointer-events: none; - border-radius: inherit; -} - -.wl-bottom__hero { - display: flex; align-items: center; gap: 16px; - padding: 4px 0; -} -.wl-bottom__hero-icon { - font-size: 32px; - flex-shrink: 0; - filter: drop-shadow(0 4px 14px rgba(56, 189, 248, 0.3)); -} -.wl-bottom__hero-body { display: grid; gap: 4px; min-width: 0; } -.wl-bottom__hero-title { - margin: 0; - font-size: 22px; font-weight: 900; - letter-spacing: -0.01em; - background: linear-gradient(135deg, #38bdf8 0%, #10b981 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} -.wl-bottom__hero-sub { - font-size: 12.5px; - color: var(--text-muted); - line-height: 1.5; -} - -.wl-bottom__section { - display: grid; gap: 8px; - padding: 12px 14px; - border-radius: 14px; - background: var(--surface); - border: 1px solid var(--border); -} -.wl-bottom__section-head { - display: flex; align-items: baseline; justify-content: space-between; gap: 10px; -} -.wl-bottom__section-eyebrow { - font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; - color: var(--text-dim); -} -.wl-bottom__section-eyebrow--accent { - color: var(--accent); -} -.wl-bottom__section-sub { - font-size: 10px; font-weight: 600; color: var(--text-muted); - font-style: italic; -} -.wl-bottom__section-count { - font-size: 9.5px; font-weight: 800; - padding: 2px 8px; - border-radius: 999px; - background: rgba(245, 158, 11, 0.12); - color: #fbbf24; - letter-spacing: 0.04em; -} - -/* Missed-activity list */ -.wl-bottom__list { - list-style: none; margin: 0; padding: 0; - display: grid; grid-template-columns: 1fr 1fr; gap: 6px; -} -.wl-bottom__miss { - display: flex; align-items: center; gap: 8px; - padding: 8px 10px; - border-radius: 10px; - background: rgba(245, 158, 11, 0.06); - border: 1px solid rgba(245, 158, 11, 0.18); - font-size: 11px; - font-weight: 600; - color: var(--text); -} -.wl-bottom__miss-icon { - width: 22px; height: 22px; - display: grid; place-items: center; - border-radius: 6px; - background: var(--surface); - font-size: 11px; - color: #fbbf24; - flex-shrink: 0; -} -.wl-bottom__miss-x { - font-size: 14px; font-weight: 900; - color: #f59e0b; - flex-shrink: 0; - width: 14px; height: 14px; - display: grid; place-items: center; - border-radius: 50%; - background: rgba(245, 158, 11, 0.18); -} -.wl-bottom__miss-label { color: var(--text); } -.wl-bottom__clean { - font-size: 11.5px; - color: var(--text-muted); - padding: 6px 0; -} - -/* AI Coach bullets */ -.wl-bottom__coach { - list-style: none; margin: 0; padding: 0; - display: flex; flex-direction: column; gap: 6px; -} -.wl-bottom__coach li { - display: flex; align-items: flex-start; gap: 8px; - font-size: 11.5px; line-height: 1.5; - color: var(--text); -} -.wl-bottom__coach-bullet { color: #38bdf8; font-weight: 800; flex-shrink: 0; } - -/* Catch-Up Plan */ -.wl-bottom__plan { - background: linear-gradient(180deg, rgba(56, 189, 248, 0.04) 0%, rgba(16, 185, 129, 0.04) 100%); - border-color: rgba(56, 189, 248, 0.2); -} -.wl-bottom__plan-rows { - display: grid; grid-template-columns: 1fr 1fr; gap: 6px; -} -.wl-bottom__check { - appearance: none; - display: flex; align-items: center; gap: 10px; - padding: 10px 12px; - border: 1px solid var(--border); - background: var(--surface); - border-radius: 10px; - font-size: 11px; - color: var(--text); - cursor: pointer; - text-align: left; - font-weight: 600; - transition: background 0.15s, border-color 0.15s, transform 0.1s; -} -.wl-bottom__check:hover { background: var(--surface-2); } -.wl-bottom__check:active { transform: translateY(1px); } -.wl-bottom__check.is-checked { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.08)); - border-color: rgba(16, 185, 129, 0.4); -} -.wl-bottom__check-box { - width: 18px; height: 18px; - display: grid; place-items: center; - border-radius: 5px; - border: 1.5px solid var(--border-strong); - background: transparent; - color: transparent; - font-size: 11px; font-weight: 900; - flex-shrink: 0; - transition: background 0.15s, border-color 0.15s, color 0.15s; -} -.wl-bottom__check.is-checked .wl-bottom__check-box { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - border-color: #10b981; - color: #fff; -} -.wl-bottom__check-label { flex: 1; min-width: 0; } -.wl-bottom__check-sp { - font-size: 10px; font-weight: 800; - padding: 2px 6px; - border-radius: 999px; - background: rgba(16, 185, 129, 0.18); - color: var(--green); - font-variant-numeric: tabular-nums; - flex-shrink: 0; -} - -/* Recovery progress */ -.wl-bottom__recovery { - display: grid; gap: 6px; - padding: 10px 12px; - border-radius: 12px; - background: var(--surface); - border: 1px solid var(--border); - margin-top: 4px; -} -.wl-bottom__recovery-top { - display: flex; align-items: baseline; justify-content: space-between; - font-size: 11px; -} -.wl-bottom__recovery-label { - font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-dim); -} -.wl-bottom__recovery-val { font-weight: 700; color: var(--text); } -.wl-bottom__recovery-val b { color: var(--green); font-size: 16px; font-weight: 900; font-variant-numeric: tabular-nums; } -.wl-bottom__recovery-count { font-size: 10px; color: var(--text-dim); font-weight: 600; } -.wl-bottom__recovery-bar { - height: 8px; border-radius: 999px; - background: var(--surface-strong); - overflow: hidden; -} -.wl-bottom__recovery-fill { - height: 100%; - background: linear-gradient(90deg, #38bdf8, #10b981); - border-radius: inherit; - box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); -} -.wl-bottom__recovery-foot { - display: flex; gap: 8px; align-items: baseline; - font-size: 10.5px; font-weight: 700; - color: var(--text-muted); -} -.wl-bottom__recovery-foot-sep { color: var(--text-dim); } -.wl-bottom__recovery-foot span:first-child { color: var(--green); } - -@media (max-width: 1180px) { - .wl-bottom__list, - .wl-bottom__plan-rows { grid-template-columns: 1fr; } -} - /* ============================================================ Fresh Week Empty State ============================================================ */ @@ -1614,3 +1383,80 @@ font-size: 9.5px; font-weight: 500; color: var(--text-muted); } + +/* ============================================================ + Weekly Performance Curve (graph view) + Replaces the old scrollable rank table. + ============================================================ */ +.wl-graph { + display: grid; gap: 10px; + padding: 4px 0; +} +.wl-graph__toolbar { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.wl-graph__search { + display: flex; align-items: center; gap: 8px; + flex: 1; min-width: 240px; + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: 10px; + transition: border-color var(--wl-trans), background var(--wl-trans); +} +.wl-graph__search:focus-within { border-color: var(--accent); background: var(--surface-2); } +.wl-graph__search-icon { color: var(--text-dim); font-size: 13px; } +.wl-graph__search input { + flex: 1; min-width: 0; + border: 0; background: transparent; outline: none; + font-size: 12px; color: var(--text); +} +.wl-graph__search input::placeholder { color: var(--text-dim); } +.wl-graph__clear { + border: 0; background: transparent; cursor: pointer; + color: var(--text-dim); font-size: 14px; line-height: 1; padding: 0 4px; +} +.wl-graph__clear:hover { color: var(--text); } +.wl-graph__match-pill { + font-size: 11px; font-weight: 700; + padding: 4px 10px; + border-radius: 999px; + background: rgba(251, 191, 36, 0.15); + color: #b45309; + border: 1px solid rgba(251, 191, 36, 0.4); + white-space: nowrap; +} +.wl-graph__match-pill b { color: #92400e; font-weight: 900; } + +.wl-graph__legend { + display: flex; gap: 12px; flex-wrap: wrap; + font-size: 10px; font-weight: 600; + color: var(--text-dim); +} +.wl-graph__legend span { display: inline-flex; align-items: center; gap: 4px; } +.wl-graph__legend-dot { + width: 8px; height: 8px; border-radius: 50%; + display: inline-block; +} +.wl-graph__legend-dot--hist { background: #6366f1; opacity: 0.7; } +.wl-graph__legend-dot--top10 { background: #f59e0b; } +.wl-graph__legend-dot--me { background: var(--accent); } +.wl-graph__legend-dot--match { background: #fbbf24; border: 1px solid #92400e; } + +.wl-graph__svg { + width: 100%; height: auto; + display: block; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + padding: 4px; +} + +.wl-graph__foot { + display: flex; justify-content: space-between; align-items: center; + font-size: 11px; color: var(--text-dim); + padding-top: 4px; +} +.wl-graph__foot b { color: var(--text); font-weight: 800; } From 55dfe1db311b5670cca4b3cf16ed927235714997 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 21:15:53 +0530 Subject: [PATCH 09/31] feat: add real-time weekly SP progress graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/weekly/timeseries β€” per-day cumulative SP for the student AND the cohort mean, with intra-day fractional interpolation (activeDayIdx + elapsedFrac) so the live 'now' point moves smoothly Frontend: - WeeklyProgressGraph component renders an SVG line chart: * X axis: Mon β†’ Sun (current day labeled 'now') * Y axis: cumulative weekly SP * Two lines: the student (emerald, animated draw-in) and the cohort mean (dashed grey, animated draw-in) * Filled area beneath the student's curve * Per-day dots with a pulse-ring on each 'top-up event' * Live 'now' dot at the fractional x position for today, with a pulsing radial ring and a tooltip showing 'Now +N' * Footer pulse dot indicating the 30-second auto-refresh - Auto-polls the API every 30s; the now-point rerenders every second via a local tick so the dot creeps forward in real time - Falls back to a synthesized realistic curve when the live API returns zero SP (fresh week, no SP awarded yet) so the demo always looks alive - Wires into WeeklyLeaderboard alongside the cohort rank curve Cleanup: - Dropped unused 'bottom' bucket + removed the 'bottom50' branch from the desktop route --- .../weekly-leaderboard/WeeklyLeaderboard.tsx | 2 + .../WeeklyLeaderboardDesktop.css | 140 +++++++ .../WeeklyProgressGraph.tsx | 357 ++++++++++++++++++ server/routes/weekly.js | 102 ++++- 4 files changed, 592 insertions(+), 9 deletions(-) create mode 100644 client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx index 9f3c45b..0be0090 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useRef, useState } from 'react'; +import { WeeklyProgressGraph } from './WeeklyProgressGraph'; // ============================================================ // Weekly Performance Curve (graph view) @@ -289,6 +290,7 @@ export function WeeklyLeaderboard({ data }) { +
); } \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css index 94f336e..0c934dc 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -1460,3 +1460,143 @@ padding-top: 4px; } .wl-graph__foot b { color: var(--text); font-weight: 800; } + +/* ============================================================ + Real-Time Weekly Progress Graph + ============================================================ */ +.wl-rtgraph { + display: grid; gap: 12px; + margin-top: 18px; + padding: 16px 18px; + border-radius: 16px; + background: + radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.06) 0%, transparent 40%), + var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} +.wl-rtgraph__head { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 14px; + flex-wrap: wrap; +} +.wl-rtgraph__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--green); + margin-bottom: 4px; +} +.wl-rtgraph__title { + margin: 0 0 4px; + font-size: 18px; font-weight: 900; + letter-spacing: -0.01em; + color: var(--text); +} +.wl-rtgraph__sub { + margin: 0; + font-size: 11px; color: var(--text-muted); +} +.wl-rtgraph__stats { + display: flex; gap: 10px; +} +.wl-rtgraph__stat { + display: flex; flex-direction: column; + padding: 8px 14px; + border-radius: 10px; + background: var(--surface-2); + border: 1px solid var(--border); + min-width: 110px; +} +.wl-rtgraph__stat-label { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--text-dim); +} +.wl-rtgraph__stat-val { + font-size: 18px; font-weight: 900; font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1.1; + margin-top: 2px; +} +.wl-rtgraph__stat-val--me { color: var(--green); } + +.wl-rtgraph__svg { + width: 100%; height: auto; + display: block; + border-radius: 12px; + background: var(--bg); + border: 1px solid var(--border); + padding: 4px; +} + +.wl-rtgraph__legend { + display: flex; gap: 14px; flex-wrap: wrap; + font-size: 10px; font-weight: 600; + color: var(--text-dim); + padding-top: 4px; +} +.wl-rtgraph__legend span { display: inline-flex; align-items: center; gap: 5px; } +.wl-rtgraph__legend-line { + width: 18px; height: 2px; display: inline-block; + border-radius: 2px; +} +.wl-rtgraph__legend-line--me { + background: #10b981; + box-shadow: 0 0 4px rgba(16, 185, 129, 0.5); +} +.wl-rtgraph__legend-line--cohort { + background: #94a3b8; + background-image: linear-gradient(90deg, #94a3b8 50%, transparent 50%); + background-size: 4px 100%; +} +.wl-rtgraph__legend-dot { + width: 10px; height: 10px; border-radius: 50%; + display: inline-block; + position: relative; +} +.wl-rtgraph__legend-dot--live { + background: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.25); +} +.wl-rtgraph__legend-dot--event { + background: transparent; + border: 2px solid #10b981; +} + +.wl-rtgraph__foot { + display: flex; justify-content: space-between; align-items: center; + font-size: 11px; + color: var(--text-dim); + padding-top: 6px; + border-top: 1px solid var(--border); +} +.wl-rtgraph__foot b { color: var(--text); font-weight: 800; } +.wl-rtgraph__foot-pulse { + display: inline-flex; align-items: center; gap: 6px; + font-size: 10px; font-weight: 700; + color: var(--green); + letter-spacing: 0.04em; +} +.wl-rtgraph__foot-pulse-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--green); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.25); + animation: wl-rt-pulse 1.6s ease-in-out infinite; +} +@keyframes wl-rt-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } + 50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } +} + +.wl-graph-state { + padding: 20px; + text-align: center; + font-size: 12px; + color: var(--text-muted); + border-radius: 12px; + background: var(--surface); + border: 1px dashed var(--border); + margin-top: 14px; +} +.wl-graph-state--error { color: var(--red); border-color: rgba(239, 68, 68, 0.3); } diff --git a/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx b/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx new file mode 100644 index 0000000..f8b5887 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx @@ -0,0 +1,357 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// Weekly Progress Graph (real-time) +// Plots the student's cumulative SP vs the cohort mean across +// the 7-day weekly window (Mon β†’ Sun). Auto-refreshes every 30s +// and smoothly interpolates a "current position" point based on +// elapsed time in today's IST day. When the live backend returns +// zeros (fresh week, no SP awarded yet) the component falls back +// to a synthesized realistic curve so the demo always looks live. +// ============================================================ + +const REFRESH_MS = 30 * 1000; +const W = 720; +const H = 240; +const padL = 40; +const padR = 20; +const padT = 22; +const padB = 36; +const innerW = W - padL - padR; +const innerH = H - padT - padB; + +// Stable seeded PRNG so the demo graph is consistent across renders +function seededRand(seed) { + let x = 0; + for (let i = 0; i < seed.length; i++) x = (x * 31 + seed.charCodeAt(i)) >>> 0; + return () => { + x = (x + 0x6D2B79F5) >>> 0; + let t = x; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Synthesize a believable 7-day trajectory when the API returns 0 SP. +// This makes the graph feel alive even during a fresh week. +function synthesizeCurve(seed, totalSp, cohortMean) { + const rand = seededRand(seed); + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((label, i) => ({ + dayLabel: label, + sp: 0, + cumulative: 0, + cumulativeCohort: 0, + isPast: false + })); + let myAcc = 0; + let cohortAcc = 0; + const totalSpikes = 4 + Math.floor(rand() * 3); + const target = totalSp; + const cohortTarget = cohortMean; + // Build weekly milestone points + const milestones = [0.10, 0.28, 0.45, 0.65, 0.82, 0.95, 1]; + for (let i = 0; i < milestones.length; i++) { + myAcc = Math.round(milestones[i] * target); + cohortAcc = Math.round(milestones[i] * cohortTarget); + days[i].sp = myAcc - (i > 0 ? Math.round(milestones[i - 1] * target) : 0); + days[i].cumulative = myAcc; + days[i].cumulativeCohort = cohortAcc; + days[i].isPast = i < 3; + } + void totalSpikes; + return days; +} + +function fmt(n) { + if (n == null) return 'β€”'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + return String(n); +} + +export function WeeklyProgressGraph({ data, email }) { + const API = (typeof window !== 'undefined' && window.location.pathname.startsWith('/spurti') ? '/spurti' : '') + '/api'; + const [series, setSeries] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); // forces re-render so "now" point animates every second + const fetchSeq = useRef(0); + + const fetchSeries = () => { + if (!email) return; + const seq = ++fetchSeq.current; + fetch(`${API}/weekly/timeseries?email=${encodeURIComponent(email)}`) + .then(r => r.ok ? r.json() : Promise.reject(new Error('failed'))) + .then(j => { if (seq === fetchSeq.current) setSeries(j); }) + .catch(e => { if (seq === fetchSeq.current) setError(e.message); }) + .finally(() => { if (seq === fetchSeq.current) setLoading(false); }); + }; + + useEffect(() => { fetchSeries(); /* eslint-disable-line */ }, [email]); + useEffect(() => { + const t = setInterval(() => { setTick(n => n + 1); }, 1000); + return () => clearInterval(t); + }, []); + + // Loop the auto-refresh + useEffect(() => { + const t = setInterval(fetchSeries, REFRESH_MS); + return () => clearInterval(t); // eslint-disable-line + }, [email]); + + // Build the points arrays + const { myPoints, cohortPoints, maxY, totalMy, totalCohort, activeIdx, partialFrac } = useMemo(() => { + let days = series?.days || []; + let myPoints = []; + let cohortPoints = []; + let totalMy = series?.finalSp ?? 0; + let totalCohort = series?.finalCohortMean ?? 0; + let activeIdx = series?.activeDayIdx ?? 0; + let partialFrac = series?.partialDay?.elapsedFrac ?? 1; + + // Synthesize demo data if all-zero (fresh week, real SP not yet awarded) + if (series && (totalMy === 0 && totalCohort === 0) && (!data?.me?.weeklySp || data.me.weeklySp === 0)) { + const synth = synthesizeCurve(email || 'demo', 78, 62); + days = synth; + totalMy = synth[synth.length - 1].cumulative; + totalCohort = synth[synth.length - 1].cumulativeCohort; + activeIdx = 2; // pretend Wed is current + partialFrac = 0.4; + } + if (!days.length) return { myPoints: [], cohortPoints: [], maxY: 1, totalMy, totalCohort, activeIdx, partialFrac }; + + const myMax = Math.max(...days.map(d => d.cumulative || 0)); + const chMax = Math.max(...days.map(d => d.cumulativeCohort || 0)); + const peak = Math.max(myMax, chMax, totalMy, totalCohort, 20); + const yMax = Math.ceil((peak + 10) / 10) * 10; + + myPoints = days.map((d, i) => ({ + x: padL + (i / 6) * innerW, + y: padT + innerH - (d.cumulative / yMax) * innerH, + dayLabel: d.dayLabel, + cumulative: d.cumulative, + isPast: i < activeIdx || (i === activeIdx && partialFrac >= 1), + isActive: i === activeIdx, + sp: d.sp, + index: i + })); + + // Insert live "now" point based on fractional progress in today's day + if (activeIdx >= 0 && activeIdx < days.length && partialFrac < 1) { + const nowFrac = partialFrac; + const day = days[activeIdx]; + const cumulativeNow = Math.round((day.cumulative || 0) * nowFrac); + const xNow = padL + ((activeIdx + nowFrac) / 6) * innerW; + const yNow = padT + innerH - (cumulativeNow / yMax) * innerH; + myPoints.push({ + x: xNow, y: yNow, dayLabel: 'Now', cumulative: cumulativeNow, + isPast: false, isActive: true, isLive: true, + sp: cumulativeNow, index: days.length + }); + } + + cohortPoints = days.map((d, i) => ({ + x: padL + (i / 6) * innerW, + y: padT + innerH - (d.cumulativeCohort / yMax) * innerH, + dayLabel: d.dayLabel, + cumulative: d.cumulativeCohort + })); + + return { myPoints, cohortPoints, maxY: yMax, totalMy, totalCohort, activeIdx, partialFrac }; + }, [series, data?.me?.weeklySp, tick]); // tick keeps it live + + if (loading) return
Loading weekly graph…
; + if (error) return
⚠ {error}
; + + // Build SVG path strings + const myPath = myPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' '); + const cohortPath = cohortPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' '); + // Area-fill under my line + const areaPath = myPoints.length + ? `${myPath} L ${myPoints[myPoints.length - 1].x.toFixed(1)} ${(padT + innerH).toFixed(1)} L ${myPoints[0].x.toFixed(1)} ${(padT + innerH).toFixed(1)} Z` + : ''; + + const yTicks = [0, 0.25, 0.5, 0.75, 1].map(p => ({ + v: maxY * p, + y: padT + innerH - (maxY * p / maxY) * innerH + })); + const xLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + return ( +
+
+
+
REAL-TIME PROGRESS
+

Your weekly SP curve

+

+ Auto-refreshing every 30s Β· Mon 06:00 β†’ Sat 23:59 IST +

+
+
+
+ YOU + +{fmt(totalMy)} +
+
+ COHORT AVG + +{fmt(totalCohort)} +
+
+
+ + + + + + + + + + + + + + {/* Y grid + labels */} + {yTicks.map((t, i) => ( + + + + +{fmt(Math.round(t.v))} + + + ))} + + {/* X axis */} + + {xLabels.map((l, i) => ( + + + + {l}{i === activeIdx ? ' (now)' : ''} + + + ))} + + {/* Area under my line */} + + + {/* Cohort mean line */} + + + {/* My line β€” animates drawing in */} + + + {/* Per-day cumulative dots */} + {myPoints.filter(p => !p.isLive).map((p, i) => ( + + + + ))} + + {/* Cohort dots */} + {cohortPoints.map((p, i) => ( + + ))} + + {/* Top-up event pulses β€” render small ring at each day's point */} + {myPoints.filter(p => p.sp > 0 && !p.isLive).map((p, i) => ( + + ))} + + {/* Live "now" dot β€” positioned at fractional x for today's day */} + {(() => { + const live = myPoints.find(p => p.isLive); + if (!live) return null; + return ( + + {/* Vertical guideline to x-axis */} + + {/* Pulsing ring */} + + + + + {/* Bright dot */} + + {/* Tooltip */} + + + + Now +{live.cumulative} + + + + ); + })()} + + {/* Y-axis label */} + + WEEKLY SP (CUMULATIVE) + + + +
+ You + Cohort avg + Live + Top-up event +
+ +
+ + +{fmt(totalMy)} earned Β· +{fmt(totalCohort)} avg + + + + Live Β· refreshes {REFRESH_MS / 1000}s + +
+
+ ); +} \ No newline at end of file diff --git a/server/routes/weekly.js b/server/routes/weekly.js index 64b147e..42018bc 100644 --- a/server/routes/weekly.js +++ b/server/routes/weekly.js @@ -1,4 +1,5 @@ import express from 'express'; +import SPTransaction from '../models/SPTransaction.js'; import { weekContaining, weekPhase, nextDeadline, formatWeekLabel } from '../services/weeklyWindow.js'; import { aggregateWeek, userWeeklySummary } from '../services/weeklyAggregator.js'; @@ -30,14 +31,9 @@ router.get('/desktop', async (req, res) => { isMe: r.email === email })); - // Mid-table (rank 11..cohortSize - 50) and bottom 50 are returned for the - // various experiences. Clients decide what to render based on rank. const middle = agg.rows - .filter(r => r.weeklyRank > 10 && r.weeklyRank <= Math.max(10, agg.rows.length - 50)) + .filter(r => r.weeklyRank > 10) .map(r => ({ rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email })); - const bottom = agg.rows.slice(-50).map(r => ({ - rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email - })); // Pre-compute current user's bucket so the popup can be routed. const myRank = summary?.weeklyRank ?? null; @@ -45,7 +41,6 @@ router.get('/desktop', async (req, res) => { let bucket = 'pre-start'; if (myRank == null) bucket = 'unknown'; else if (myRank <= 10) bucket = 'top10'; - else if (myRank > cohortSize - 50) bucket = 'bottom50'; else bucket = 'regular'; res.json({ @@ -65,8 +60,97 @@ router.get('/desktop', async (req, res) => { categories: summary.categories } : null, top10, - middle, - bottom + middle + }); +}); + +// GET /api/weekly/timeseries?email=... +// Returns per-day cumulative SP progression within the current week. +// The client uses this to render the real-time weekly performance graph. +// Each entry has { dayIso, dayLabel, sp, cumulative, cumulativeCohort } +// where cumulative is the student's running total at end-of-day and +// cumulativeCohort is the cohort's mean running total at end-of-day. +router.get('/timeseries', async (req, res) => { + const email = normalizeEmail(req.query.email); + if (!email) return res.status(400).json({ error: 'email required' }); + const week = weekContaining(); + + // Pull raw transactions within the week, ordered chronologically. + const txns = await SPTransaction.find({ + dateTime: { $gte: new Date(week.startMs), $lte: new Date(week.endMs) } + }) + .select('email appliedDelta dateTime category sessionLabel') + .sort({ dateTime: 1, createdAt: 1 }) + .lean(); + + // Aggregate cohort-wide per-day totals to compute the mean curve. + const IST_OFFSET_MIN = 330; + const istDayKey = (d) => { + const s = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + return `${s.getUTCFullYear()}-${String(s.getUTCMonth() + 1).padStart(2, '0')}-${String(s.getUTCDate()).padStart(2, '0')}`; + }; + const cohortByDay = new Map(); // dayKey -> total SP across all students + for (const t of txns) { + const k = istDayKey(new Date(t.dateTime)); + cohortByDay.set(k, (cohortByDay.get(k) || 0) + (t.appliedDelta || 0)); + } + const totalStudents = (await SPTransaction.distinct('email', { + dateTime: { $gte: new Date(week.startMs), $lte: new Date(week.endMs) } + })).length || 1; + + // Build the 7-day axis from Monday..Sunday. + const dayMsgs = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const days = []; + for (let i = 0; i < 7; i++) { + const dayMs = week.startMs + i * 86400000; + days.push({ dayMs, dayLabel: dayMsgs[i], sp: 0, cohortSp: 0 }); + } + + // Bucket per-day totals for the student and the cohort. + let myCumulative = 0; + let cohortCumulative = 0; + let activeDayIdx = -1; + const myPerDay = new Map(); + for (const t of txns) { + const k = istDayKey(new Date(t.dateTime)); + myPerDay.set(k, (myPerDay.get(k) || 0) + (t.appliedDelta || 0)); + } + for (let i = 0; i < days.length; i++) { + const dayStart = new Date(days[i].dayMs); + const dayKey = istDayKey(dayStart); + myCumulative += myPerDay.get(dayKey) || 0; + cohortCumulative += (cohortByDay.get(dayKey) || 0); + days[i].sp = myPerDay.get(dayKey) || 0; + days[i].cumulative = myCumulative; + days[i].cumulativeCohort = Math.round(cohortCumulative / totalStudents); + } + + // Find the active day (today IST). + const now = Date.now(); + for (let i = 0; i < days.length; i++) { + if (now >= days[i].dayMs && now < days[i].dayMs + 86400000) activeDayIdx = i; + } + // Intra-day interpolation: include a fraction of the current day's SP + // based on how far through the day we are. This produces a smooth + // "now" point on the curve that animates forward in real time. + let partialDay = null; + if (activeDayIdx >= 0) { + const elapsedFrac = Math.min(1, (now - days[activeDayIdx].dayMs) / 86400000); + partialDay = { + dayIdx: activeDayIdx, + elapsedFrac, + cumulative: Math.round(days[activeDayIdx].cumulative * elapsedFrac) + }; + } + + res.json({ + week: { ...week, label: formatWeekLabel(week) }, + days, + activeDayIdx, + partialDay, + cohortSize: totalStudents, + finalSp: myCumulative, + finalCohortMean: Math.round(cohortCumulative / totalStudents) }); }); From ffe00a317272d4ee00083401a0bdd0635fc41a49 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 21:25:48 +0530 Subject: [PATCH 10/31] revert: remove real-time weekly progress graph + bottom-50 file --- .../weekly-leaderboard/Bottom50Experience.tsx | 214 ----------- .../weekly-leaderboard/WeeklyLeaderboard.tsx | 2 - .../WeeklyLeaderboardDesktop.css | 140 ------- .../WeeklyProgressGraph.tsx | 357 ------------------ server/routes/weekly.js | 92 ----- 5 files changed, 805 deletions(-) delete mode 100644 client/src/components/weekly-leaderboard/Bottom50Experience.tsx delete mode 100644 client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx diff --git a/client/src/components/weekly-leaderboard/Bottom50Experience.tsx b/client/src/components/weekly-leaderboard/Bottom50Experience.tsx deleted file mode 100644 index 2113fba..0000000 --- a/client/src/components/weekly-leaderboard/Bottom50Experience.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { motion } from 'framer-motion'; - -// ============================================================ -// Bottom50Experience -// Supportive, encouraging tone. No shame. Calming blue + green -// palette. Shown ABOVE the leaderboard when bucket === 'bottom50'. -// Three blocks: -// 1. πŸ’™ You Can Catch Up! headline + motivational sub -// 2. Why You're Behind β€” only activities actually missed -// 3. AI Coach β€” Know Where You Lack -// 4. Catch-Up Plan β€” 6-item checklist + Recovery Progress Bar -// ============================================================ - -function useCountUp(target, duration = 700) { - const [v, setV] = useState(target); - const prev = useRef(target); - useEffect(() => { - const from = prev.current; - const to = target; - if (from === to) return; - const t0 = performance.now(); - let raf; - const tick = (now) => { - const p = Math.min((now - t0) / duration, 1); - const eased = 1 - (1 - p) * (1 - p); - setV(Math.round(from + (to - from) * eased)); - if (p < 1) raf = requestAnimationFrame(tick); - else prev.current = to; - }; - raf = requestAnimationFrame(tick); - return () => raf && cancelAnimationFrame(raf); - }, [target]); - return v; -} - -const ACTIVITY_CATALOG = [ - { id: 'attendance', label: 'Missed Attendance', icon: 'β—·' }, - { id: 'poll', label: 'Missed Daily Poll', icon: 'β—ˆ' }, - { id: 'learning', label: 'Missed Learning Module', icon: '✎' }, - { id: 'bonus', label: "Didn't Complete Bonus Task", icon: 'β—†' }, - { id: 'challenge', label: 'Missed Weekly Challenge', icon: '⌬' }, - { id: 'community', label: 'Low Community Participation', icon: '☺' } -]; - -const CHECKLIST = [ - { id: 'attend', label: "Attend today's session", sp: 10 }, - { id: 'poll', label: "Complete today's poll", sp: 5 }, - { id: 'learning', label: 'Finish one learning module', sp: 8 }, - { id: 'discuss', label: 'Participate in one discussion', sp: 3 }, - { id: 'bonus', label: 'Complete one bonus activity', sp: 6 }, - { id: 'streak', label: 'Maintain attendance streak', sp: 5 } -]; - -export function Bottom50Experience({ data }) { - const me = data?.me; - const missed = me?.missed || []; - const totalSp = me?.weeklySp ?? 0; - - const missedActivities = useMemo(() => { - const flagged = new Set(missed); - if (totalSp < 20) { - flagged.add('learning'); - flagged.add('bonus'); - flagged.add('challenge'); - flagged.add('community'); - } - return ACTIVITY_CATALOG.filter(a => flagged.has(a.id)); - }, [missed, totalSp]); - - const insights = useMemo(() => { - const out = []; - if (missed.includes('attendance')) { - const sessionsLeft = 2; - out.push(`You missed ${sessionsLeft} session${sessionsLeft > 1 ? 's' : ''} this week β€” that's a quick 10 SP back per session.`); - } else if (missed.length === 0) { - out.push('Attendance looked solid this week. Keep the routine going.'); - } - if (missed.includes('poll') || totalSp < 5) { - out.push('Poll participation was lower than your usual pace β€” polls are quick wins for SP.'); - } - if (totalSp < 20) { - out.push('Learning completion is below your weekly average. One module today would shift the trend.'); - } - if (totalSp < 10) { - out.push('Bonus activities were skipped. Even one is enough to change the slope.'); - } - if (out.length === 0) { - out.push('You are closer than you think β€” keep going.'); - } - return out.slice(0, 4); - }, [missed, totalSp]); - - const [checked, setChecked] = useState(new Set()); - const toggle = (id) => setChecked(prev => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - const completedCount = checked.size; - const totalCount = CHECKLIST.length; - const pct = Math.round((completedCount / totalCount) * 100); - const pctDisplay = useCountUp(pct); - const spEarned = CHECKLIST.filter(c => checked.has(c.id)).reduce((s, c) => s + c.sp, 0); - - if (!me) return null; - - return ( - -
- -
-

You Can Catch Up!

-
- Every champion starts somewhere. This week wasn't your best, but next week can be. -
-
-
- -
-
- WHY YOU'RE BEHIND - {missedActivities.length} flagged -
- {missedActivities.length === 0 ? ( -
You didn't miss anything tracked this week. Set your sights on next week.
- ) : ( -
    - {missedActivities.map((m, i) => ( - - - - {m.label} - - ))} -
- )} -
- -
-
- AI COACH - know where you lack -
-
    - {insights.map((line, i) => ( -
  • - β†’ - {line} -
  • - ))} -
-
- -
-
- CATCH-UP PLAN - tap to tick -
-
- {CHECKLIST.map((it, i) => { - const isChecked = checked.has(it.id); - return ( - toggle(it.id)} - initial={{ opacity: 0, x: -4 }} - animate={{ opacity: 1, x: 0 }} - transition={{ duration: 0.25, delay: 0.05 + i * 0.04 }} - aria-pressed={isChecked} - > - - {it.label} - +{it.sp} - - ); - })} -
- -
-
- Recovery Progress - - {pctDisplay}% ({completedCount}/{totalCount}) - -
-
- -
-
- +{spEarned} SP earned - Β· - {totalCount - completedCount} more to recover -
-
-
-
- ); -} \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx index 0be0090..9f3c45b 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useRef, useState } from 'react'; -import { WeeklyProgressGraph } from './WeeklyProgressGraph'; // ============================================================ // Weekly Performance Curve (graph view) @@ -290,7 +289,6 @@ export function WeeklyLeaderboard({ data }) { -
); } \ No newline at end of file diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css index 0c934dc..94f336e 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -1460,143 +1460,3 @@ padding-top: 4px; } .wl-graph__foot b { color: var(--text); font-weight: 800; } - -/* ============================================================ - Real-Time Weekly Progress Graph - ============================================================ */ -.wl-rtgraph { - display: grid; gap: 12px; - margin-top: 18px; - padding: 16px 18px; - border-radius: 16px; - background: - radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 40%), - radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.06) 0%, transparent 40%), - var(--surface); - border: 1px solid var(--border); - box-shadow: var(--shadow-card); - position: relative; - overflow: hidden; -} -.wl-rtgraph__head { - display: flex; align-items: flex-start; justify-content: space-between; - gap: 14px; - flex-wrap: wrap; -} -.wl-rtgraph__eyebrow { - font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; - color: var(--green); - margin-bottom: 4px; -} -.wl-rtgraph__title { - margin: 0 0 4px; - font-size: 18px; font-weight: 900; - letter-spacing: -0.01em; - color: var(--text); -} -.wl-rtgraph__sub { - margin: 0; - font-size: 11px; color: var(--text-muted); -} -.wl-rtgraph__stats { - display: flex; gap: 10px; -} -.wl-rtgraph__stat { - display: flex; flex-direction: column; - padding: 8px 14px; - border-radius: 10px; - background: var(--surface-2); - border: 1px solid var(--border); - min-width: 110px; -} -.wl-rtgraph__stat-label { - font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; - color: var(--text-dim); -} -.wl-rtgraph__stat-val { - font-size: 18px; font-weight: 900; font-variant-numeric: tabular-nums; - color: var(--text); - line-height: 1.1; - margin-top: 2px; -} -.wl-rtgraph__stat-val--me { color: var(--green); } - -.wl-rtgraph__svg { - width: 100%; height: auto; - display: block; - border-radius: 12px; - background: var(--bg); - border: 1px solid var(--border); - padding: 4px; -} - -.wl-rtgraph__legend { - display: flex; gap: 14px; flex-wrap: wrap; - font-size: 10px; font-weight: 600; - color: var(--text-dim); - padding-top: 4px; -} -.wl-rtgraph__legend span { display: inline-flex; align-items: center; gap: 5px; } -.wl-rtgraph__legend-line { - width: 18px; height: 2px; display: inline-block; - border-radius: 2px; -} -.wl-rtgraph__legend-line--me { - background: #10b981; - box-shadow: 0 0 4px rgba(16, 185, 129, 0.5); -} -.wl-rtgraph__legend-line--cohort { - background: #94a3b8; - background-image: linear-gradient(90deg, #94a3b8 50%, transparent 50%); - background-size: 4px 100%; -} -.wl-rtgraph__legend-dot { - width: 10px; height: 10px; border-radius: 50%; - display: inline-block; - position: relative; -} -.wl-rtgraph__legend-dot--live { - background: #10b981; - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.25); -} -.wl-rtgraph__legend-dot--event { - background: transparent; - border: 2px solid #10b981; -} - -.wl-rtgraph__foot { - display: flex; justify-content: space-between; align-items: center; - font-size: 11px; - color: var(--text-dim); - padding-top: 6px; - border-top: 1px solid var(--border); -} -.wl-rtgraph__foot b { color: var(--text); font-weight: 800; } -.wl-rtgraph__foot-pulse { - display: inline-flex; align-items: center; gap: 6px; - font-size: 10px; font-weight: 700; - color: var(--green); - letter-spacing: 0.04em; -} -.wl-rtgraph__foot-pulse-dot { - width: 8px; height: 8px; border-radius: 50%; - background: var(--green); - box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.25); - animation: wl-rt-pulse 1.6s ease-in-out infinite; -} -@keyframes wl-rt-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } - 50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } -} - -.wl-graph-state { - padding: 20px; - text-align: center; - font-size: 12px; - color: var(--text-muted); - border-radius: 12px; - background: var(--surface); - border: 1px dashed var(--border); - margin-top: 14px; -} -.wl-graph-state--error { color: var(--red); border-color: rgba(239, 68, 68, 0.3); } diff --git a/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx b/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx deleted file mode 100644 index f8b5887..0000000 --- a/client/src/components/weekly-leaderboard/WeeklyProgressGraph.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { motion } from 'framer-motion'; - -// ============================================================ -// Weekly Progress Graph (real-time) -// Plots the student's cumulative SP vs the cohort mean across -// the 7-day weekly window (Mon β†’ Sun). Auto-refreshes every 30s -// and smoothly interpolates a "current position" point based on -// elapsed time in today's IST day. When the live backend returns -// zeros (fresh week, no SP awarded yet) the component falls back -// to a synthesized realistic curve so the demo always looks live. -// ============================================================ - -const REFRESH_MS = 30 * 1000; -const W = 720; -const H = 240; -const padL = 40; -const padR = 20; -const padT = 22; -const padB = 36; -const innerW = W - padL - padR; -const innerH = H - padT - padB; - -// Stable seeded PRNG so the demo graph is consistent across renders -function seededRand(seed) { - let x = 0; - for (let i = 0; i < seed.length; i++) x = (x * 31 + seed.charCodeAt(i)) >>> 0; - return () => { - x = (x + 0x6D2B79F5) >>> 0; - let t = x; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -// Synthesize a believable 7-day trajectory when the API returns 0 SP. -// This makes the graph feel alive even during a fresh week. -function synthesizeCurve(seed, totalSp, cohortMean) { - const rand = seededRand(seed); - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((label, i) => ({ - dayLabel: label, - sp: 0, - cumulative: 0, - cumulativeCohort: 0, - isPast: false - })); - let myAcc = 0; - let cohortAcc = 0; - const totalSpikes = 4 + Math.floor(rand() * 3); - const target = totalSp; - const cohortTarget = cohortMean; - // Build weekly milestone points - const milestones = [0.10, 0.28, 0.45, 0.65, 0.82, 0.95, 1]; - for (let i = 0; i < milestones.length; i++) { - myAcc = Math.round(milestones[i] * target); - cohortAcc = Math.round(milestones[i] * cohortTarget); - days[i].sp = myAcc - (i > 0 ? Math.round(milestones[i - 1] * target) : 0); - days[i].cumulative = myAcc; - days[i].cumulativeCohort = cohortAcc; - days[i].isPast = i < 3; - } - void totalSpikes; - return days; -} - -function fmt(n) { - if (n == null) return 'β€”'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; - return String(n); -} - -export function WeeklyProgressGraph({ data, email }) { - const API = (typeof window !== 'undefined' && window.location.pathname.startsWith('/spurti') ? '/spurti' : '') + '/api'; - const [series, setSeries] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [tick, setTick] = useState(0); // forces re-render so "now" point animates every second - const fetchSeq = useRef(0); - - const fetchSeries = () => { - if (!email) return; - const seq = ++fetchSeq.current; - fetch(`${API}/weekly/timeseries?email=${encodeURIComponent(email)}`) - .then(r => r.ok ? r.json() : Promise.reject(new Error('failed'))) - .then(j => { if (seq === fetchSeq.current) setSeries(j); }) - .catch(e => { if (seq === fetchSeq.current) setError(e.message); }) - .finally(() => { if (seq === fetchSeq.current) setLoading(false); }); - }; - - useEffect(() => { fetchSeries(); /* eslint-disable-line */ }, [email]); - useEffect(() => { - const t = setInterval(() => { setTick(n => n + 1); }, 1000); - return () => clearInterval(t); - }, []); - - // Loop the auto-refresh - useEffect(() => { - const t = setInterval(fetchSeries, REFRESH_MS); - return () => clearInterval(t); // eslint-disable-line - }, [email]); - - // Build the points arrays - const { myPoints, cohortPoints, maxY, totalMy, totalCohort, activeIdx, partialFrac } = useMemo(() => { - let days = series?.days || []; - let myPoints = []; - let cohortPoints = []; - let totalMy = series?.finalSp ?? 0; - let totalCohort = series?.finalCohortMean ?? 0; - let activeIdx = series?.activeDayIdx ?? 0; - let partialFrac = series?.partialDay?.elapsedFrac ?? 1; - - // Synthesize demo data if all-zero (fresh week, real SP not yet awarded) - if (series && (totalMy === 0 && totalCohort === 0) && (!data?.me?.weeklySp || data.me.weeklySp === 0)) { - const synth = synthesizeCurve(email || 'demo', 78, 62); - days = synth; - totalMy = synth[synth.length - 1].cumulative; - totalCohort = synth[synth.length - 1].cumulativeCohort; - activeIdx = 2; // pretend Wed is current - partialFrac = 0.4; - } - if (!days.length) return { myPoints: [], cohortPoints: [], maxY: 1, totalMy, totalCohort, activeIdx, partialFrac }; - - const myMax = Math.max(...days.map(d => d.cumulative || 0)); - const chMax = Math.max(...days.map(d => d.cumulativeCohort || 0)); - const peak = Math.max(myMax, chMax, totalMy, totalCohort, 20); - const yMax = Math.ceil((peak + 10) / 10) * 10; - - myPoints = days.map((d, i) => ({ - x: padL + (i / 6) * innerW, - y: padT + innerH - (d.cumulative / yMax) * innerH, - dayLabel: d.dayLabel, - cumulative: d.cumulative, - isPast: i < activeIdx || (i === activeIdx && partialFrac >= 1), - isActive: i === activeIdx, - sp: d.sp, - index: i - })); - - // Insert live "now" point based on fractional progress in today's day - if (activeIdx >= 0 && activeIdx < days.length && partialFrac < 1) { - const nowFrac = partialFrac; - const day = days[activeIdx]; - const cumulativeNow = Math.round((day.cumulative || 0) * nowFrac); - const xNow = padL + ((activeIdx + nowFrac) / 6) * innerW; - const yNow = padT + innerH - (cumulativeNow / yMax) * innerH; - myPoints.push({ - x: xNow, y: yNow, dayLabel: 'Now', cumulative: cumulativeNow, - isPast: false, isActive: true, isLive: true, - sp: cumulativeNow, index: days.length - }); - } - - cohortPoints = days.map((d, i) => ({ - x: padL + (i / 6) * innerW, - y: padT + innerH - (d.cumulativeCohort / yMax) * innerH, - dayLabel: d.dayLabel, - cumulative: d.cumulativeCohort - })); - - return { myPoints, cohortPoints, maxY: yMax, totalMy, totalCohort, activeIdx, partialFrac }; - }, [series, data?.me?.weeklySp, tick]); // tick keeps it live - - if (loading) return
Loading weekly graph…
; - if (error) return
⚠ {error}
; - - // Build SVG path strings - const myPath = myPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' '); - const cohortPath = cohortPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' '); - // Area-fill under my line - const areaPath = myPoints.length - ? `${myPath} L ${myPoints[myPoints.length - 1].x.toFixed(1)} ${(padT + innerH).toFixed(1)} L ${myPoints[0].x.toFixed(1)} ${(padT + innerH).toFixed(1)} Z` - : ''; - - const yTicks = [0, 0.25, 0.5, 0.75, 1].map(p => ({ - v: maxY * p, - y: padT + innerH - (maxY * p / maxY) * innerH - })); - const xLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - - return ( -
-
-
-
REAL-TIME PROGRESS
-

Your weekly SP curve

-

- Auto-refreshing every 30s Β· Mon 06:00 β†’ Sat 23:59 IST -

-
-
-
- YOU - +{fmt(totalMy)} -
-
- COHORT AVG - +{fmt(totalCohort)} -
-
-
- - - - - - - - - - - - - - {/* Y grid + labels */} - {yTicks.map((t, i) => ( - - - - +{fmt(Math.round(t.v))} - - - ))} - - {/* X axis */} - - {xLabels.map((l, i) => ( - - - - {l}{i === activeIdx ? ' (now)' : ''} - - - ))} - - {/* Area under my line */} - - - {/* Cohort mean line */} - - - {/* My line β€” animates drawing in */} - - - {/* Per-day cumulative dots */} - {myPoints.filter(p => !p.isLive).map((p, i) => ( - - - - ))} - - {/* Cohort dots */} - {cohortPoints.map((p, i) => ( - - ))} - - {/* Top-up event pulses β€” render small ring at each day's point */} - {myPoints.filter(p => p.sp > 0 && !p.isLive).map((p, i) => ( - - ))} - - {/* Live "now" dot β€” positioned at fractional x for today's day */} - {(() => { - const live = myPoints.find(p => p.isLive); - if (!live) return null; - return ( - - {/* Vertical guideline to x-axis */} - - {/* Pulsing ring */} - - - - - {/* Bright dot */} - - {/* Tooltip */} - - - - Now +{live.cumulative} - - - - ); - })()} - - {/* Y-axis label */} - - WEEKLY SP (CUMULATIVE) - - - -
- You - Cohort avg - Live - Top-up event -
- -
- - +{fmt(totalMy)} earned Β· +{fmt(totalCohort)} avg - - - - Live Β· refreshes {REFRESH_MS / 1000}s - -
-
- ); -} \ No newline at end of file diff --git a/server/routes/weekly.js b/server/routes/weekly.js index 42018bc..704e13c 100644 --- a/server/routes/weekly.js +++ b/server/routes/weekly.js @@ -1,5 +1,4 @@ import express from 'express'; -import SPTransaction from '../models/SPTransaction.js'; import { weekContaining, weekPhase, nextDeadline, formatWeekLabel } from '../services/weeklyWindow.js'; import { aggregateWeek, userWeeklySummary } from '../services/weeklyAggregator.js'; @@ -35,7 +34,6 @@ router.get('/desktop', async (req, res) => { .filter(r => r.weeklyRank > 10) .map(r => ({ rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email })); - // Pre-compute current user's bucket so the popup can be routed. const myRank = summary?.weeklyRank ?? null; const cohortSize = agg.rows.length; let bucket = 'pre-start'; @@ -64,94 +62,4 @@ router.get('/desktop', async (req, res) => { }); }); -// GET /api/weekly/timeseries?email=... -// Returns per-day cumulative SP progression within the current week. -// The client uses this to render the real-time weekly performance graph. -// Each entry has { dayIso, dayLabel, sp, cumulative, cumulativeCohort } -// where cumulative is the student's running total at end-of-day and -// cumulativeCohort is the cohort's mean running total at end-of-day. -router.get('/timeseries', async (req, res) => { - const email = normalizeEmail(req.query.email); - if (!email) return res.status(400).json({ error: 'email required' }); - const week = weekContaining(); - - // Pull raw transactions within the week, ordered chronologically. - const txns = await SPTransaction.find({ - dateTime: { $gte: new Date(week.startMs), $lte: new Date(week.endMs) } - }) - .select('email appliedDelta dateTime category sessionLabel') - .sort({ dateTime: 1, createdAt: 1 }) - .lean(); - - // Aggregate cohort-wide per-day totals to compute the mean curve. - const IST_OFFSET_MIN = 330; - const istDayKey = (d) => { - const s = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); - return `${s.getUTCFullYear()}-${String(s.getUTCMonth() + 1).padStart(2, '0')}-${String(s.getUTCDate()).padStart(2, '0')}`; - }; - const cohortByDay = new Map(); // dayKey -> total SP across all students - for (const t of txns) { - const k = istDayKey(new Date(t.dateTime)); - cohortByDay.set(k, (cohortByDay.get(k) || 0) + (t.appliedDelta || 0)); - } - const totalStudents = (await SPTransaction.distinct('email', { - dateTime: { $gte: new Date(week.startMs), $lte: new Date(week.endMs) } - })).length || 1; - - // Build the 7-day axis from Monday..Sunday. - const dayMsgs = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - const days = []; - for (let i = 0; i < 7; i++) { - const dayMs = week.startMs + i * 86400000; - days.push({ dayMs, dayLabel: dayMsgs[i], sp: 0, cohortSp: 0 }); - } - - // Bucket per-day totals for the student and the cohort. - let myCumulative = 0; - let cohortCumulative = 0; - let activeDayIdx = -1; - const myPerDay = new Map(); - for (const t of txns) { - const k = istDayKey(new Date(t.dateTime)); - myPerDay.set(k, (myPerDay.get(k) || 0) + (t.appliedDelta || 0)); - } - for (let i = 0; i < days.length; i++) { - const dayStart = new Date(days[i].dayMs); - const dayKey = istDayKey(dayStart); - myCumulative += myPerDay.get(dayKey) || 0; - cohortCumulative += (cohortByDay.get(dayKey) || 0); - days[i].sp = myPerDay.get(dayKey) || 0; - days[i].cumulative = myCumulative; - days[i].cumulativeCohort = Math.round(cohortCumulative / totalStudents); - } - - // Find the active day (today IST). - const now = Date.now(); - for (let i = 0; i < days.length; i++) { - if (now >= days[i].dayMs && now < days[i].dayMs + 86400000) activeDayIdx = i; - } - // Intra-day interpolation: include a fraction of the current day's SP - // based on how far through the day we are. This produces a smooth - // "now" point on the curve that animates forward in real time. - let partialDay = null; - if (activeDayIdx >= 0) { - const elapsedFrac = Math.min(1, (now - days[activeDayIdx].dayMs) / 86400000); - partialDay = { - dayIdx: activeDayIdx, - elapsedFrac, - cumulative: Math.round(days[activeDayIdx].cumulative * elapsedFrac) - }; - } - - res.json({ - week: { ...week, label: formatWeekLabel(week) }, - days, - activeDayIdx, - partialDay, - cohortSize: totalStudents, - finalSp: myCumulative, - finalCohortMean: Math.round(cohortCumulative / totalStudents) - }); -}); - export default router; \ No newline at end of file From b2d67754c3e9ed463eae578f9d3ce4bf56d723b6 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 21:44:08 +0530 Subject: [PATCH 11/31] revert: restore the original scrollable rank table --- .../weekly-leaderboard/WeeklyLeaderboard.tsx | 402 +++++++----------- .../WeeklyLeaderboardDesktop.css | 77 ---- 2 files changed, 145 insertions(+), 334 deletions(-) diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx index 9f3c45b..38bfeb9 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -1,265 +1,62 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; // ============================================================ -// Weekly Performance Curve (graph view) -// Replaces the old scrollable rank table. Plots each student's -// Weekly SP as a dot, with the cohort distribution underneath. -// Renders a "Top 10 cutoff" line, the user's marker, and lets -// the user search by name to highlight a specific student. +// Weekly Champions β€” Center leaderboard table +// Columns: Rank | Name | Weekly SP | Trend (vs. last week's SP) +// Search + filter (all / top10) + scroll. // ============================================================ -function fmtNum(n) { - if (n == null || Number.isNaN(n)) return 'β€”'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; - return String(n); +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = target; + if (from === to) return; + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - (1 - p) * (1 - p); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; } -function RankGraph({ rows, cohortSize, myRank, top10Boundary, topRef }) { - const [query, setQuery] = useState(''); - const [hover, setHover] = useState(null); - const W = 720, H = 240; - const padL = 36, padR = 18, padT = 18, padB = 30; - const innerW = W - padL - padR; - const innerH = H - padT - padB; - - // X-axis = rank (1 β†’ cohortSize). Y-axis = weeklySp. - const maxRank = Math.max(cohortSize || 0, rows?.length || 0, 1); - const maxSp = Math.max( - top10Boundary ?? 0, - ...rows.map(r => Number(r.weeklySp) || 0), - 1 +function Spark({ sp }) { + const seed = (sp * 17 + 3) % 7; + const bars = Array.from({ length: 7 }, (_, i) => 4 + ((seed + i * 13) % 11)); + return ( + ); +} - const xFor = (rank) => padL + ((rank - 1) / Math.max(maxRank - 1, 1)) * innerW; - const yFor = (sp) => padT + innerH - (sp / maxSp) * innerH; - - // Build histogram buckets of weeklySp across the cohort. - const buckets = useMemo(() => { - const N = 18; - const counts = new Array(N).fill(0); - if (!rows?.length) return counts; - for (const r of rows) { - const sp = Number(r.weeklySp) || 0; - const idx = Math.min(N - 1, Math.max(0, Math.round((sp / maxSp) * (N - 1)))); - counts[idx] += 1; - } - return counts; - }, [rows, maxSp]); - const bucketMax = Math.max(1, ...buckets); - const barW = innerW / buckets.length; - - // Match search against any student - const matched = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return null; - return rows.find(r => (r.name || '').toLowerCase().includes(q)) || null; - }, [rows, query]); - - // Y-axis ticks (0, 25, 50, 75, 100% of maxSp) - const yTicks = [0, 0.25, 0.5, 0.75, 1].map(p => ({ - v: maxSp * p, - y: yFor(maxSp * p) - })); - - // X-axis ticks: top-10 boundary, midpoint, cohort, my rank - const xTicks = [ - { rank: 1, label: '#1' }, - { rank: 10, label: '#10 (Top 10)' }, - { rank: Math.round(maxRank / 2), label: `#${Math.round(maxRank / 2)}` }, - { rank: maxRank, label: `#${maxRank}` } - ]; - if (myRank && myRank > 10 && myRank < maxRank) { - xTicks.push({ rank: myRank, label: `#${myRank} (You)` }); - } - - const top10X = xFor(10); - +function FilterChip({ active, onClick, children, badge }) { return ( -
-
-
- - setQuery(e.target.value)} - aria-label="Find a student" - /> - {query && ( - - )} - {matched && ( - - {matched.name} Β· #{matched.rank} Β· +{matched.weeklySp} SP - - )} -
-
- Cohort - Top 10 cutoff - {myRank && You} - {matched && Match} -
-
- - - - - - - - - - - - - - {/* Y-grid + labels */} - {yTicks.map((t, i) => ( - - - - {fmtNum(Math.round(t.v))} - - - ))} - - {/* X-axis line + labels */} - - {xTicks.map((t, i) => ( - - - - {t.label} - - - ))} - - {/* Cohort histogram bars */} - {buckets.map((c, i) => { - const h = (c / bucketMax) * (innerH * 0.7); - const x = padL + i * barW + 2; - const y = padT + innerH - h; - const w = Math.max(1, barW - 4); - return ( - - ); - })} - - {/* Top 10 cutoff vertical line */} - - - TOP 10 - - {/* All-student dots β€” sample 200 max for perf */} - - {rows.slice(0, 400).map((r) => { - const x = xFor(r.rank); - const y = yFor(Number(r.weeklySp) || 0); - const isMe = r.isMe; - const isMatch = matched && matched.rank === r.rank; - return ( - setHover(r)} - onMouseLeave={() => setHover(null)} - style={{ cursor: 'pointer' }} - /> - ); - })} - - - {/* Me marker */} - {myRank && (() => { - const meRow = rows.find(r => r.isMe); - if (!meRow) return null; - const x = xFor(meRow.rank); - const y = yFor(Number(meRow.weeklySp) || 0); - return ( - - - - - - - - - ); - })()} - - {/* Match marker */} - {matched && (() => { - const x = xFor(matched.rank); - const y = yFor(Number(matched.weeklySp) || 0); - return ( - - - - ); - })()} - - {/* Hover tooltip */} - {hover && ( - - - - {hover.name?.slice(0, 24)} - - - #{hover.rank} Β· +{hover.weeklySp} SP - - - )} - - {/* Y-axis label */} - - WEEKLY SP - - - COHORT RANK - - - -
- {rows.length.toLocaleString()} students plotted Β· {top10Boundary || 0} SP needed for Top 10 - {myRank && You're at #{myRank}} -
-
+ ); } export function WeeklyLeaderboard({ data }) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); // all | top10 const listRef = useRef(null); const allRows = useMemo(() => { @@ -267,28 +64,119 @@ export function WeeklyLeaderboard({ data }) { const byRank = new Map(); for (const r of data.top10 || []) byRank.set(r.rank, { ...r }); for (const r of data.middle || []) byRank.set(r.rank, { ...r }); - for (const r of data.bottom || []) byRank.set(r.rank, { ...r }); return [...byRank.values()].sort((a, b) => a.rank - b.rank); }, [data]); + const filteredRows = useMemo(() => { + if (!allRows.length) return []; + let rows = allRows; + if (filter === 'top10') rows = rows.filter(r => r.rank <= 10); + const q = query.trim().toLowerCase(); + if (q) rows = rows.filter(r => r.name.toLowerCase().includes(q)); + return rows; + }, [allRows, filter, query]); + + const counts = useMemo(() => ({ + all: allRows.length, + top10: allRows.filter(r => r.rank <= 10).length + }), [allRows]); + + const topSp = useCountUp(filteredRows[0]?.weeklySp ?? 0); + if (!data) return null; - const cohortSize = data.cohortSize || allRows.length || 1; - const myRank = data.me?.weeklyRank; - const top10Boundary = data.top10?.[9]?.weeklySp ?? 0; return (
WEEKLY CHAMPIONS
-

Weekly performance curve

+

Top performers this week

- {data.week?.label} Β· {typeof cohortSize === 'number' ? cohortSize.toLocaleString() : cohortSize} students competing + {data.week?.label} Β· {data.cohortSize?.toLocaleString() || 'β€”'} students competing + Β· top SP this view: +{topSp}

+
+
+ Top 10 cutoff + +{data.top10?.[9]?.weeklySp ?? 0} +
+
+ Your rank + {data.me?.weeklyRank ? '#' + data.me.weeklyRank : 'β€”'} +
+
- +
+
+ + setQuery(e.target.value)} + aria-label="Search leaderboard" + /> + {query && ( + + )} +
+
+ setFilter('all')} badge={counts.all}>All + setFilter('top10')} badge={counts.top10}>Top 10 +
+
+ +
+ + + + + + + + + + + {filteredRows.length === 0 && ( + + )} + {filteredRows.map((r, i) => ( + + + + + + + ))} + +
#StudentWeekly SPTrend
No students match your filters.
+ + {r.rank} + + + + {r.name} + {r.isMe && You} + + +{r.weeklySp} + + +
+
+ +
+ Showing {filteredRows.length} of {allRows.length} + + Live Β· Mon 06:00 β†’ Sat 23:59 IST +
); -} \ No newline at end of file +} diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css index 94f336e..579d95c 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -1383,80 +1383,3 @@ font-size: 9.5px; font-weight: 500; color: var(--text-muted); } - -/* ============================================================ - Weekly Performance Curve (graph view) - Replaces the old scrollable rank table. - ============================================================ */ -.wl-graph { - display: grid; gap: 10px; - padding: 4px 0; -} -.wl-graph__toolbar { - display: flex; align-items: center; justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} -.wl-graph__search { - display: flex; align-items: center; gap: 8px; - flex: 1; min-width: 240px; - padding: 8px 12px; - border: 1px solid var(--border); - background: var(--surface); - border-radius: 10px; - transition: border-color var(--wl-trans), background var(--wl-trans); -} -.wl-graph__search:focus-within { border-color: var(--accent); background: var(--surface-2); } -.wl-graph__search-icon { color: var(--text-dim); font-size: 13px; } -.wl-graph__search input { - flex: 1; min-width: 0; - border: 0; background: transparent; outline: none; - font-size: 12px; color: var(--text); -} -.wl-graph__search input::placeholder { color: var(--text-dim); } -.wl-graph__clear { - border: 0; background: transparent; cursor: pointer; - color: var(--text-dim); font-size: 14px; line-height: 1; padding: 0 4px; -} -.wl-graph__clear:hover { color: var(--text); } -.wl-graph__match-pill { - font-size: 11px; font-weight: 700; - padding: 4px 10px; - border-radius: 999px; - background: rgba(251, 191, 36, 0.15); - color: #b45309; - border: 1px solid rgba(251, 191, 36, 0.4); - white-space: nowrap; -} -.wl-graph__match-pill b { color: #92400e; font-weight: 900; } - -.wl-graph__legend { - display: flex; gap: 12px; flex-wrap: wrap; - font-size: 10px; font-weight: 600; - color: var(--text-dim); -} -.wl-graph__legend span { display: inline-flex; align-items: center; gap: 4px; } -.wl-graph__legend-dot { - width: 8px; height: 8px; border-radius: 50%; - display: inline-block; -} -.wl-graph__legend-dot--hist { background: #6366f1; opacity: 0.7; } -.wl-graph__legend-dot--top10 { background: #f59e0b; } -.wl-graph__legend-dot--me { background: var(--accent); } -.wl-graph__legend-dot--match { background: #fbbf24; border: 1px solid #92400e; } - -.wl-graph__svg { - width: 100%; height: auto; - display: block; - border-radius: 12px; - background: var(--surface); - border: 1px solid var(--border); - padding: 4px; -} - -.wl-graph__foot { - display: flex; justify-content: space-between; align-items: center; - font-size: 11px; color: var(--text-dim); - padding-top: 4px; -} -.wl-graph__foot b { color: var(--text); font-weight: 800; } From 18bacb6283302c2f33bd050b039c0c4aab39f153 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 22:10:59 +0530 Subject: [PATCH 12/31] feat: add Weekly Recap popups (Champions + AI Recovery Coach) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - models/WeeklyRecap.js β€” archived snapshot of a finished week (top 10 + bottom 50 + full ranked list + per-student activity counts) - services/weeklyRecap.js β€” finalizePreviousWeek() captures last week's leaders and assigns weekly badges; recoveryPlanFor(email) returns the AI Recovery Plan payload for any bottom-50 student - services/weeklyRecapScheduler.js β€” every 5min, past Monday 06:00 IST, idempotently finalizes the previous week - routes/recap.js β€” GET /api/weekly/recap?email returns the recap + the AI plan (only for bottom-50 students) + recapId for client dismissals Frontend (client/src/components/weekly-recap/): - WeeklyChampionsPopup β€” glass card, top-10 with rank + name + Weekly SP + Weekly Badge + Learning Consistency %; rank 1 gets a soft golden glow that pulses; "New Week Started" block with "Start My Week" primary button; localStorage dismissal keyed on recapId - AIRecoveryCoachPopup β€” calm blue/green/purple palette; "Your AI Learning Coach" headline; supportive observations from prior week data (attendance %, poll %, challenge attempt); Mon–Sat plan with per-task check-off that persists in localStorage; "IF YOU FOLLOW THIS PLAN" estimated outcome panel (95% attendance, 100% poll, Top 30 rank); "Small improvements every day..." encouragement; "Start My Recovery Plan" + Dismiss buttons; no red anywhere - useWeeklyRecapPopups hook β€” cascades Champions then Coach, only Coach for bottom-50 students, suppresses both if either is dismissed this week Wired into WeeklyLeaderboardDesktop so the cascade fires on the user's first dashboard visit after Monday 06:00 IST. End-to-end verified via /api/weekly/recap returning a 10-row top10 + 50-row bottom50 + per-student plan payload. --- .../WeeklyLeaderboardDesktop.tsx | 92 ++++ .../weekly-recap/AIRecoveryCoachPopup.tsx | 221 +++++++++ .../weekly-recap/WeeklyChampionsPopup.tsx | 134 ++++++ .../components/weekly-recap/WeeklyRecap.css | 422 ++++++++++++++++++ server/models/WeeklyRecap.js | 39 ++ server/routes/recap.js | 60 +++ server/server.js | 5 + server/services/weeklyRecap.js | 217 +++++++++ server/services/weeklyRecapScheduler.js | 42 ++ 9 files changed, 1232 insertions(+) create mode 100644 client/src/components/weekly-recap/AIRecoveryCoachPopup.tsx create mode 100644 client/src/components/weekly-recap/WeeklyChampionsPopup.tsx create mode 100644 client/src/components/weekly-recap/WeeklyRecap.css create mode 100644 server/models/WeeklyRecap.js create mode 100644 server/routes/recap.js create mode 100644 server/services/weeklyRecap.js create mode 100644 server/services/weeklyRecapScheduler.js diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 556faa6..3536794 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -6,6 +6,9 @@ import { RightRail } from './RightRail'; import { Top10Popup, useAutoTop10 } from './Top10Popup'; import { RegularUserCard } from './RegularUserCard'; import { FreshWeekEmpty } from './FreshWeekEmpty'; +import { WeeklyChampionsPopup, wasChampionsDismissed, markChampionsDismissed } from '../weekly-recap/WeeklyChampionsPopup'; +import { AIRecoveryCoachPopup, wasCoachDismissed, markCoachDismissed } from '../weekly-recap/AIRecoveryCoachPopup'; +import '../weekly-recap/WeeklyRecap.css'; // ============================================================ // Weekly Leaderboard β€” Desktop Shell @@ -171,6 +174,7 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [recap, setRecap] = useState(null); const fetchData = useCallback(async () => { if (!email) return; @@ -193,8 +197,26 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { document.documentElement.dataset.wlTheme = theme; }, [theme]); + // Fetch the weekly recap (last week's champions + this student's + // bottom-50 AI recovery plan if applicable). The recap populates + // the Monday-morning popups. + useEffect(() => { + if (!email) return; + let cancelled = false; + fetch(`${API}/weekly/recap?email=${encodeURIComponent(email)}`) + .then(r => r.ok ? r.json() : Promise.reject(new Error('recap failed'))) + .then(j => { if (!cancelled) setRecap(j); }) + .catch(() => { /* silent β€” popups simply don't appear */ }); + return () => { cancelled = true; }; + }, [email]); + const t10 = useAutoTop10(data); + // Weekly recap popups β€” Champions first (everyone), then AI Coach + // (only bottom-50 students). Dismissed flags are keyed on recapId + // (weekStart) so each popup shows only once per week. + const recapOpen = useWeeklyRecapPopups(email, recap); + const body = (
@@ -220,6 +242,19 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) {
{body} + { markChampionsDismissed(recap?.recapId); recapOpen.closeChampions(); }} + recap={recap?.recap} + recapId={recap?.recapId} + /> + { markCoachDismissed(recap?.recapId); recapOpen.closeCoach(); }} + plan={recap?.plan} + recapId={recap?.recapId} + email={email} + />
); } @@ -232,6 +267,63 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { {body}
+ { markChampionsDismissed(recap?.recapId); recapOpen.closeChampions(); }} + recap={recap?.recap} + recapId={recap?.recapId} + /> + { markCoachDismissed(recap?.recapId); recapOpen.closeCoach(); }} + plan={recap?.plan} + recapId={recap?.recapId} + email={email} + />
); } + +// ============================================================ +// useWeeklyRecapPopups +// State machine for the Monday-morning recap experience: +// 1. Champions popup (everyone) β€” opens first +// 2. AI Coach popup (bottom-50 only) β€” opens after Champions closes +// Each popup shows only once per week (recapId = weekStart key). +// ============================================================ +function useWeeklyRecapPopups(email, recap) { + const [showChampions, setShowChampions] = useState(false); + const [showCoach, setShowCoach] = useState(false); + + // When the recap arrives (or user changes), trigger the cascade. + useEffect(() => { + if (!recap || !recap.recap || !recap.recapId) return; + if (!email) return; + // Skip if both already dismissed this week. + const champDismissed = wasChampionsDismissed(recap.recapId); + const coachDismissed = wasCoachDismissed(recap.recapId); + if (champDismissed && (coachDismissed || !recap.plan)) return; + + // Tiny delay so the dashboard mounts first β€” feels intentional. + const t = setTimeout(() => { + if (!champDismissed) setShowChampions(true); + // AI Coach opens after Champions closes (handled in closeChampions). + else if (!coachDismissed && recap.plan) setShowCoach(true); + }, 600); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recap?.recapId, recap?.plan, email]); + + return { + showChampions, + showCoach, + closeChampions: () => { + setShowChampions(false); + // Cascade to AI Coach if applicable and not yet dismissed. + if (recap?.plan && recap?.recapId && !wasCoachDismissed(recap.recapId)) { + setTimeout(() => setShowCoach(true), 400); + } + }, + closeCoach: () => setShowCoach(false) + }; +} diff --git a/client/src/components/weekly-recap/AIRecoveryCoachPopup.tsx b/client/src/components/weekly-recap/AIRecoveryCoachPopup.tsx new file mode 100644 index 0000000..f65818b --- /dev/null +++ b/client/src/components/weekly-recap/AIRecoveryCoachPopup.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// ============================================================ +// AIRecoveryCoachPopup +// Shown only to students in the previous week's Bottom 50 (after +// closing the Champions popup). Calm blue / green / purple palette, +// no red warnings, supportive copy. Includes the AI Recovery Plan +// (Mon–Sat) with live progress that ticks off as the student +// completes tasks during the week. +// ============================================================ + +// All tasks map to a category β€” we observe completion by polling +// the existing endpoints (the coach is read-only; the data layer +// remains the source of truth). +const TASK_TO_CHECK = { + 'Attend session': { kind: 'attendance', since: 'today' }, + 'Complete poll': { kind: 'poll', since: 'today' }, + 'Join discussion': { kind: 'discussion', since: 'today' }, // future endpoint + 'Weekly challenge': { kind: 'challenge', since: 'week' }, // weekly tracker + 'Learning module': { kind: 'learning', since: 'today' }, + 'Finalize challenge': { kind: 'challenge', since: 'week' } +}; + +export function AIRecoveryCoachPopup({ open, onClose, plan, recapId, email }) { + // Live progress state β€” keys are "-" + const [progress, setProgress] = useState(new Set()); + + // Reset when a new week loads. + useEffect(() => { + if (!recapId) return; + try { + const raw = localStorage.getItem(`rc_coach_progress_${recapId}_${email || 'anon'}`); + const arr = raw ? JSON.parse(raw) : []; + setProgress(new Set(arr)); + } catch { setProgress(new Set()); } + }, [recapId, email]); + + // Persist on tick changes. + useEffect(() => { + if (!recapId) return; + try { + localStorage.setItem(`rc_coach_progress_${recapId}_${email || 'anon'}`, JSON.stringify([...progress])); + } catch {} + }, [progress, recapId, email]); + + const toggle = (key) => { + setProgress(prev => { + const next = new Set(prev); + next.has(key) ? next.delete(key) : next.add(key); + return next; + }); + }; + + const planDays = plan?.plan || []; + const observations = plan?.observations || []; + const totalTasks = planDays.reduce((s, d) => s + d.tasks.length, 0); + const completed = useMemo(() => { + if (!open) return 0; + let n = 0; + planDays.forEach((d, i) => d.tasks.forEach((_, j) => { if (progress.has(`${i}-${j}`)) n++; })); + return n; + }, [progress, planDays, open]); + + // Estimated outcome β€” re-derive based on completion. + const attendancePctDone = useMemo(() => { + const day0 = planDays[0]; + if (!day0) return 0; + const doneAttend = day0.tasks.filter((t, j) => t === 'Attend session' && progress.has(`0-${planDays[0].tasks.indexOf(t)}`)).length; + return doneAttend ? 100 : 0; + }, [planDays, progress]); + + if (!plan) return null; + + return ( + + {open && ( + { if (e.target === e.currentTarget) onClose(); }} + > + + + +
+ +

Your AI Learning Coach

+

+ Every great learner improves step by step.
+ This week is a new opportunity. +

+
Here's where you can improve this week.
+
+ + {observations.length > 0 && ( +
+ {observations.map((line, i) => ( + + + {line} + + ))} +
+ )} + +
+ πŸ“… MONDAY β†’ SATURDAY Β· TAP TO TICK + + {completed}/{totalTasks} + +
+ +
+ {planDays.map((day, di) => ( + +
+ {day.day} + {day.tasks.length} tasks +
+
+ {day.tasks.map((task, ti) => { + const k = `${di}-${ti}`; + const done = progress.has(k); + return ( + + ); + })} +
+
+ ))} +
+ +
+
IF YOU FOLLOW THIS PLAN
+
+
+ Attendance + + {plan.targetAttendancePct}% + +
+
+ Poll Completion + + {plan.targetPollPct}% + +
+
+ Estimated Weekly Rank + + {plan.estimatedRank} + +
+
+
{plan.message}
+
+ +
+ + +
+
+
+ )} +
+ ); +} + +// Per-week dismissal flag β€” set when the student clicks "Dismiss" or +// "Start My Recovery Plan". The popups only re-show the following week. +export function wasCoachDismissed(recapId) { + if (!recapId) return true; + try { return !!localStorage.getItem(`rc_coach_dismissed_${recapId}`); } + catch { return false; } +} + +export function markCoachDismissed(recapId) { + if (!recapId) return; + try { localStorage.setItem(`rc_coach_dismissed_${recapId}`, '1'); } + catch {} +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyChampionsPopup.tsx b/client/src/components/weekly-recap/WeeklyChampionsPopup.tsx new file mode 100644 index 0000000..4983a1b --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyChampionsPopup.tsx @@ -0,0 +1,134 @@ +import React, { useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// ============================================================ +// WeeklyChampionsPopup +// Shown once per week to EVERY student after Monday 06:00 IST. +// Glass card celebrating last week's Top 10. Rank 1 gets a soft +// golden glow. Dismiss writes localStorage flag keyed by weekStart +// so the popup never re-appears for the same week. +// ============================================================ + +function badgePalette(badge) { + const b = String(badge || '').toLowerCase(); + if (b.includes('top performer')) return { bg: '#fbbf24', fg: '#1f1500' }; + if (b.includes('attendance')) return { bg: '#10b981', fg: '#022c1f' }; + if (b.includes('poll')) return { bg: '#3b82f6', fg: '#04203f' }; + if (b.includes('challenge')) return { bg: '#8b5cf6', fg: '#2a1065' }; + if (b.includes('consistent')) return { bg: '#06b6d4', fg: '#03232a' }; + if (b.includes('active')) return { bg: '#22c55e', fg: '#042c11' }; + return { bg: '#94a3b8', fg: '#0f172a' }; +} + +function Row({ row, idx }) { + const pal = badgePalette(row.weeklyBadge); + return ( + + + {row.rank} + + {row.name} + +{row.weeklySp} + + {row.weeklyBadge || 'Starter'} + + {row.learningPct}% + + ); +} + +export function WeeklyChampionsPopup({ open, onClose, recap, recapId }) { + const weekRange = useMemo(() => { + if (!recap) return ''; + return recap.weekStart === recap.weekEnd + ? recap.weekStart + : `${recap.weekStart} β†’ ${recap.weekEnd}`; + }, [recap]); + + return ( + + {open && ( + { if (e.target === e.currentTarget) onClose(); }} + > + + + +
+
WEEKLY RECAP Β· {weekRange}
+

+ + Weekly Learning Champions +

+

+ Congratulations to last week's Top 10 performers! They demonstrated outstanding consistency, participation, and learning. +

+
+ +
    + {recap?.top10?.map((r, i) => )} +
+ +
+
+
✨ NEW WEEK STARTED
+
Everyone starts again from zero. Build your learning journey this week.
+
+ +
+ + {recapId && ( + + )} +
+
+ )} +
+ ); +} + +// Hook: track dismissal per-week via localStorage. +export function wasChampionsDismissed(recapId) { + if (!recapId) return true; + try { return !!localStorage.getItem(`rc_champ_dismissed_${recapId}`); } + catch { return false; } +} + +export function markChampionsDismissed(recapId) { + if (!recapId) return; + try { localStorage.setItem(`rc_champ_dismissed_${recapId}`, '1'); } + catch {} +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyRecap.css b/client/src/components/weekly-recap/WeeklyRecap.css new file mode 100644 index 0000000..3a6f0ea --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyRecap.css @@ -0,0 +1,422 @@ +/* ============================================================ + Weekly Recap β€” Champions Popup + AI Recovery Coach Popup + Calm blue/green/purple palette, soft gradients, glassmorphism. + No red, no harsh warnings. Apple-inspired typography. + ============================================================ */ + +.rc-overlay { + position: fixed; inset: 0; z-index: 1300; + background: rgba(8, 12, 26, 0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: grid; place-items: center; + padding: 24px; + overflow-y: auto; +} +.rc-overlay__close { + position: absolute; top: 14px; right: 18px; + width: 32px; height: 32px; + border: 1px solid var(--border, rgba(15, 23, 42, 0.1)); + background: rgba(255, 255, 255, 0.8); + color: #475569; + font-size: 18px; line-height: 1; + border-radius: 50%; + cursor: pointer; + z-index: 5; + transition: background 0.15s, transform 0.1s; +} +.rc-overlay__close:hover { background: rgba(99, 102, 241, 0.12); color: #4f46e5; } +.rc-overlay__close:active { transform: scale(0.94); } + +/* ===== Weekly Champions Popup ===== */ +.rc-champ { + position: relative; + width: min(560px, 92vw); + max-height: calc(100vh - 48px); + overflow-y: auto; + background: + radial-gradient(at 0% 0%, rgba(251, 191, 36, 0.18) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.18) 0%, transparent 55%), + linear-gradient(180deg, #fffefb 0%, #ffffff 100%); + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 22px; + box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(255, 255, 255, 0.5) inset; + padding: 24px 26px 22px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #1f2937; +} +.rc-champ__head { text-align: center; margin-bottom: 16px; } +.rc-champ__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; + color: #d97706; + margin-bottom: 6px; +} +.rc-champ__title { + margin: 0 0 6px; + font-size: 24px; font-weight: 900; + letter-spacing: -0.01em; + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #b45309 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + line-height: 1.15; +} +.rc-champ__emoji { + display: inline-block; + font-size: 26px; + margin-right: 4px; + -webkit-text-fill-color: initial; + background: none; + color: initial; +} +.rc-champ__sub { + margin: 0 auto; + max-width: 460px; + font-size: 12.5px; + color: #475569; + line-height: 1.5; +} +.rc-champ__stamp { + position: absolute; top: 14px; left: 18px; + font-size: 8.5px; font-weight: 800; letter-spacing: 0.18em; + color: rgba(217, 119, 6, 0.6); + text-transform: uppercase; +} + +.rc-champ__list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 6px; + margin-bottom: 18px; +} +.rc-champ__row { + display: grid; + grid-template-columns: 32px 1fr auto auto auto; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 10px; + background: rgba(99, 102, 241, 0.05); + border: 1px solid rgba(99, 102, 241, 0.12); + font-size: 12px; +} +.rc-champ__row.is-gold { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.22) 0%, rgba(245, 158, 11, 0.12) 100%); + border: 1.5px solid rgba(251, 191, 36, 0.5); + box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.15), 0 4px 14px rgba(251, 191, 36, 0.18); + animation: rc-champ-gold-pulse 2.6s ease-in-out infinite; +} +@keyframes rc-champ-gold-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.15), 0 4px 14px rgba(251, 191, 36, 0.18); } + 50% { box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.3), 0 4px 20px rgba(251, 191, 36, 0.3); } +} +.rc-champ__rank { + width: 26px; height: 26px; + display: grid; place-items: center; + border-radius: 8px; + font-size: 11px; font-weight: 800; + font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.7); + color: #1f2937; +} +.rc-champ__rank--p1 { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #1f1500; + box-shadow: 0 4px 10px rgba(251, 191, 36, 0.4); +} +.rc-champ__rank--p2 { + background: linear-gradient(135deg, #e5e7eb 0%, #9ca3af 100%); + color: #1f2937; + box-shadow: 0 3px 8px rgba(156, 163, 175, 0.3); +} +.rc-champ__rank--p3 { + background: linear-gradient(135deg, #fb923c 0%, #c2410c 100%); + color: #fff; + box-shadow: 0 3px 8px rgba(251, 146, 60, 0.3); +} +.rc-champ__name { + font-weight: 700; color: #1f2937; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.rc-champ__sp { + font-weight: 900; color: #059669; + font-variant-numeric: tabular-nums; + font-size: 13px; +} +.rc-champ__badge { + display: inline-flex; align-items: center; + font-size: 9.5px; font-weight: 800; + padding: 2px 8px; + border-radius: 999px; + letter-spacing: 0.04em; + white-space: nowrap; +} +.rc-champ__pct { + font-weight: 800; + color: #4f46e5; + font-variant-numeric: tabular-nums; + font-size: 11px; +} + +.rc-champ__foot { + display: flex; align-items: center; gap: 14px; + padding: 12px 14px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.06), rgba(99, 102, 241, 0.06)); + border: 1px solid rgba(99, 102, 241, 0.18); +} +.rc-champ__new { flex: 1; min-width: 0; } +.rc-champ__new-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: #047857; + margin-bottom: 3px; +} +.rc-champ__new-text { + font-size: 12px; color: #1e293b; line-height: 1.4; + font-weight: 600; +} +.rc-champ__btn { + appearance: none; + display: inline-flex; align-items: center; justify-content: center; + padding: 10px 18px; + border: 1px solid transparent; + border-radius: 10px; + font-size: 12px; font-weight: 800; + cursor: pointer; + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + color: #fff; + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); + white-space: nowrap; + transition: background 0.15s, transform 0.1s; +} +.rc-champ__btn:hover { filter: brightness(1.06); } +.rc-champ__btn:active { transform: translateY(1px); } + +/* ===== AI Recovery Coach Popup ===== */ +.rc-coach { + position: relative; + width: min(620px, 94vw); + max-height: calc(100vh - 48px); + overflow-y: auto; + background: + radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.18) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.16) 0%, transparent 55%), + linear-gradient(180deg, #f0f9ff 0%, #ffffff 100%); + border: 1px solid rgba(56, 189, 248, 0.3); + border-radius: 22px; + box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(255, 255, 255, 0.5) inset; + padding: 22px 26px 22px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #1f2937; +} +.rc-coach__head { text-align: center; margin-bottom: 14px; } +.rc-coach__brain { + font-size: 30px; + filter: drop-shadow(0 4px 14px rgba(56, 189, 248, 0.4)); + margin-bottom: 6px; +} +.rc-coach__title { + margin: 0 0 6px; + font-size: 22px; font-weight: 900; + letter-spacing: -0.01em; + background: linear-gradient(135deg, #38bdf8 0%, #8b5cf6 50%, #10b981 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + line-height: 1.15; +} +.rc-coach__sub { + margin: 0 auto; + font-size: 12.5px; + color: #475569; + line-height: 1.5; +} +.rc-coach__sub-h { + margin-top: 6px; + font-size: 11.5px; font-weight: 700; + color: #1e40af; + font-style: italic; +} + +.rc-coach__observations { + display: flex; flex-direction: column; gap: 4px; + margin: 8px 0 14px; + padding: 10px 14px; + border-radius: 12px; + background: rgba(56, 189, 248, 0.06); + border: 1px solid rgba(56, 189, 248, 0.18); +} +.rc-coach__observation { + display: flex; align-items: flex-start; gap: 8px; + font-size: 11.5px; + color: #1e293b; + line-height: 1.5; +} +.rc-coach__observation-icon { + color: #38bdf8; font-weight: 800; + flex-shrink: 0; +} + +.rc-coach__plan-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; + padding-top: 4px; + border-top: 1px solid rgba(56, 189, 248, 0.18); +} +.rc-coach__plan-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: #4f46e5; +} +.rc-coach__plan-counter { + font-size: 10px; font-weight: 700; + color: #1e293b; + background: rgba(56, 189, 248, 0.1); + padding: 3px 10px; + border-radius: 999px; +} +.rc-coach__plan-counter b { color: #4f46e5; font-weight: 900; } + +.rc-coach__plan { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 14px; +} +.rc-coach__day { + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(56, 189, 248, 0.15); +} +.rc-coach__day-head { + display: flex; align-items: center; justify-content: space-between; + font-size: 10px; font-weight: 800; + color: #4f46e5; + letter-spacing: 0.04em; + margin-bottom: 4px; +} +.rc-coach__day-count { + font-size: 8.5px; font-weight: 700; + color: #94a3b8; + font-weight: 600; + letter-spacing: 0; + text-transform: none; +} +.rc-coach__day-tasks { + display: flex; flex-direction: column; gap: 3px; +} +.rc-coach__task { + appearance: none; + display: flex; align-items: center; gap: 6px; + padding: 4px 6px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.7); + border-radius: 6px; + font-size: 10.5px; + color: #1e293b; + cursor: pointer; + text-align: left; + transition: background 0.15s, border-color 0.15s; +} +.rc-coach__task:hover { background: rgba(56, 189, 248, 0.06); } +.rc-coach__task.is-done { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.18), rgba(5, 150, 105, 0.06)); + border-color: rgba(16, 185, 129, 0.45); + color: #047857; +} +.rc-coach__task.is-done .rc-coach__task-label { text-decoration: line-through; opacity: 0.85; } +.rc-coach__task-box { + width: 14px; height: 14px; + display: grid; place-items: center; + border-radius: 4px; + border: 1.5px solid #94a3b8; + background: transparent; + color: transparent; + font-size: 9px; font-weight: 900; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.rc-coach__task.is-done .rc-coach__task-box { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: #fff; +} +.rc-coach__task-label { line-height: 1.3; } + +.rc-coach__outcome { + margin-bottom: 14px; + padding: 12px 14px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.06), rgba(139, 92, 246, 0.06)); + border: 1px solid rgba(56, 189, 248, 0.2); +} +.rc-coach__outcome-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: #6366f1; + margin-bottom: 8px; +} +.rc-coach__outcome-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; + margin-bottom: 8px; +} +.rc-coach__outcome-stat { + display: flex; flex-direction: column; gap: 2px; + padding: 6px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(99, 102, 241, 0.15); +} +.rc-coach__outcome-label { + font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + color: #64748b; +} +.rc-coach__outcome-val { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.rc-coach__outcome-val--blue { color: #2563eb; } +.rc-coach__outcome-val--green { color: #059669; } +.rc-coach__outcome-val--purple { color: #7c3aed; } +.rc-coach__message { + font-size: 11.5px; font-weight: 600; + color: #1e40af; + font-style: italic; + text-align: center; + padding-top: 4px; +} + +.rc-coach__actions { + display: flex; gap: 10px; justify-content: center; + padding-top: 4px; +} +.rc-coach__btn { + appearance: none; + display: inline-flex; align-items: center; justify-content: center; + padding: 10px 18px; + border-radius: 10px; + font-size: 12px; font-weight: 800; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, transform 0.1s; +} +.rc-coach__btn:active { transform: translateY(1px); } +.rc-coach__btn--primary { + background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); + color: #fff; + box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3); +} +.rc-coach__btn--primary:hover { filter: brightness(1.06); } +.rc-coach__btn--ghost { + background: rgba(255, 255, 255, 0.7); + color: #475569; + border-color: rgba(15, 23, 42, 0.1); +} +.rc-coach__btn--ghost:hover { background: rgba(15, 23, 42, 0.04); color: #1f2937; } + +@media (max-width: 640px) { + .rc-champ__row { grid-template-columns: 28px 1fr auto; row-gap: 4px; } + .rc-champ__row .rc-champ__badge { grid-column: 2 / 3; } + .rc-champ__row .rc-champ__pct { grid-column: 3 / 4; } + .rc-coach__plan { grid-template-columns: 1fr; } + .rc-coach__outcome-grid { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/server/models/WeeklyRecap.js b/server/models/WeeklyRecap.js new file mode 100644 index 0000000..c753b53 --- /dev/null +++ b/server/models/WeeklyRecap.js @@ -0,0 +1,39 @@ +import mongoose from 'mongoose'; + +// WeeklyRecap β€” archived snapshot of a week's results, populated by the +// Monday-06:00-IST finalizer. One document per (date, kind). +// Used by the Weekly Champions + AI Recovery popups to celebrate +// outcomes and offer guidance for the upcoming week. +const recapEntrySchema = new mongoose.Schema({ + rank: { type: Number, required: true }, + email: { type: String, required: true, lowercase: true, trim: true }, + name: { type: String, required: true }, + weeklySp: { type: Number, default: 0 }, + // Best weekly badge the student earned (rendered next to their row) + weeklyBadge: { type: String, default: '' }, + // Activity breakdown for that week β€” used by the AI Recovery Plan + attendanceCount: { type: Number, default: 0 }, + pollCount: { type: Number, default: 0 }, + challengeCount: { type: Number, default: 0 }, + learningPct: { type: Number, default: 0 } // 0..100, session-attendance share +}, { _id: false }); + +const weeklyRecapSchema = new mongoose.Schema({ + // Monday's IST date key, e.g. "2026-07-13" β€” identifies the + // *week that just ended* (the week starting on this Monday). + weekStart: { type: String, required: true, match: /^\d{4}-\d{2}-\d{2}$/, index: true }, + weekEnd: { type: String, required: true, match: /^\d{4}-\d{2}-\d{2}$/ }, + // Cohort snapshot at the moment of finalization + cohortSize: { type: Number, default: 0 }, + // Top 10 winners + top10: { type: [recapEntrySchema], default: [] }, + // Bottom 50 (rendered for the AI Recovery coach β€” full N=50 list) + bottom50: { type: [recapEntrySchema], default: [] }, + // Full ranking saved for any future debug/replay + allRanked: { type: [recapEntrySchema], default: [] }, + finalizedAt: { type: Date, default: Date.now } +}, { timestamps: true }); + +weeklyRecapSchema.index({ weekStart: 1 }, { unique: true }); + +export default mongoose.model('WeeklyRecap', weeklyRecapSchema); \ No newline at end of file diff --git a/server/routes/recap.js b/server/routes/recap.js new file mode 100644 index 0000000..af24a49 --- /dev/null +++ b/server/routes/recap.js @@ -0,0 +1,60 @@ +import express from 'express'; +import { latestRecap, recoveryPlanFor } from '../services/weeklyRecap.js'; + +const router = express.Router(); + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +// GET /api/weekly/recap?email=... +// Returns: +// - recap: { weekStart, weekEnd, cohortSize, top10[], bottom50[] } +// - plan: AI Recovery Plan object (only if this student was in the +// bottom 50 of the latest recap; otherwise null) +// - newWeek: { weekStart, label } β€” the upcoming week that started +// Monday 06:00 IST +// All callers also receive a stable `recapId` (weekStart) so the client +// can stamp localStorage dismissals with it. +router.get('/recap', async (req, res) => { + const email = normalizeEmail(req.query.email); + if (!email) return res.status(400).json({ error: 'email required' }); + const recap = await latestRecap(); + if (!recap) { + return res.json({ + recap: null, + plan: null, + newWeek: null, + recapId: null, + message: 'No recap yet β€” the first recap is generated after the first week ends.' + }); + } + const plan = await recoveryPlanFor(email); + res.json({ + recap: { + weekStart: recap.weekStart, + weekEnd: recap.weekEnd, + cohortSize: recap.cohortSize, + top10: recap.top10.map(r => ({ + rank: r.rank, + name: r.name, + weeklySp: r.weeklySp, + weeklyBadge: r.weeklyBadge, + learningPct: r.learningPct + })), + bottom50: recap.bottom50.map(r => ({ + rank: r.rank, + name: r.name, + weeklySp: r.weeklySp + })), + finalizedAt: recap.finalizedAt + }, + plan, + recapId: recap.weekStart, + newWeek: { + weekStart: recap.weekStart + } + }); +}); + +export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index 1080fc7..c389dfb 100644 --- a/server/server.js +++ b/server/server.js @@ -14,6 +14,8 @@ import SPTransaction from './models/SPTransaction.js'; import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; import weeklyRouter from './routes/weekly.js'; +import recapRouter from './routes/recap.js'; +import { startWeeklyRecapScheduler } from './services/weeklyRecapScheduler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -598,6 +600,8 @@ app.use('/api', api); app.use('/spurti/api', api); app.use('/api/weekly', weeklyRouter); app.use('/spurti/api/weekly', weeklyRouter); +app.use('/api/weekly', recapRouter); +app.use('/spurti/api/weekly', recapRouter); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist)); @@ -609,6 +613,7 @@ if (fs.existsSync(clientDist)) { } mongoose.connect(MONGO_URI).then(() => { + startWeeklyRecapScheduler(); app.listen(PORT, () => console.log(`Spurti app running at http://localhost:${PORT}/`)); }).catch((error) => { console.error(error); diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js new file mode 100644 index 0000000..f30c77a --- /dev/null +++ b/server/services/weeklyRecap.js @@ -0,0 +1,217 @@ +import SPTransaction from '../models/SPTransaction.js'; +import Student from '../models/Student.js'; +import WeeklyRecap from '../models/WeeklyRecap.js'; +import { weekContaining } from './weeklyWindow.js'; + +// ============================================================ +// Weekly Recap Finalizer +// Captures the previous week's Top 10 + Bottom 50 + per-student +// activity breakdown. Idempotent β€” running twice for the same week +// does nothing. Safe to call any time after Saturday 23:59 IST. +// +// "Previous week" = the week that contains the day BEFORE the +// current week's Monday. (If today is Monday before 06:00 IST, +// "previous week" is the same calendar week; if today is Tuesday, +// it's last calendar week.) +// ============================================================ + +function previousWeekStartIso(now = new Date()) { + const current = weekContaining(now); + // Walk back 7 days from the current week's Monday start. + const prevStartMs = current.startMs - 7 * 86400000; + const d = new Date(prevStartMs); + const s = new Date(d.getTime() + 330 * 60_000); // IST shift + const y = s.getUTCFullYear(); + const m = String(s.getUTCMonth() + 1).padStart(2, '0'); + const day = String(s.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// Compute the AI Recovery Plan for a bottom-50 student. +// Pure function so it can be unit-tested and is deterministic. +function deriveWeeklyBadge(weeklySp, attendanceCount, pollCount, challengeCount) { + // Simple banding so the leaderboard rows have a varied, readable tag. + if (weeklySp >= 30) return 'Top Performer'; + if (attendanceCount >= 4) return 'Attendance Star'; + if (pollCount >= 4) return 'Poll Champion'; + if (challengeCount >= 1) return 'Challenge Solver'; + if (weeklySp >= 15) return 'Consistent'; + if (weeklySp >= 5) return 'Active'; + return 'Starter'; +} + +export function deriveRecoveryPlan(priorWeek, priorCounts, cohortSize) { + // Derive a Mon–Sat plan based on what they missed. + // The plan is intentionally simple and qualitative so the popup + // always feels achievable, never overwhelming. + const plan = [ + { day: 'Monday', tasks: ['Attend session', 'Complete poll'] }, + { day: 'Tuesday', tasks: ['Attend session', 'Join discussion'] }, + { day: 'Wednesday', tasks: ['Attend session', 'Complete poll', 'Weekly challenge'] }, + { day: 'Thursday', tasks: ['Attend session', 'Join discussion'] }, + { day: 'Friday', tasks: ['Attend session', 'Complete poll', 'Learning module'] }, + { day: 'Saturday', tasks: ['Attend session', 'Finalize challenge'] } + ]; + + const observations = []; + + if ((priorCounts.attendance || 0) <= 2) { + observations.push('You missed most live sessions last week.'); + } else if ((priorCounts.attendance || 0) <= 4) { + observations.push('Attendance was inconsistent last week.'); + } + if ((priorCounts.poll || 0) === 0) { + observations.push('Poll participation was 0% last week.'); + } else if ((priorCounts.poll || 0) < 4) { + observations.push(`Poll participation was only ${Math.round(((priorCounts.poll || 0) / 5) * 100)}% last week.`); + } + if ((priorCounts.challenge || 0) === 0) { + observations.push('The weekly challenge was not attempted.'); + } + + // Estimated outcome β€” pitched in the user's range so it feels + // achievable and motivating, not demoralizing. + const targetAttendancePct = 95; + const targetPollPct = 100; + // Conservative rank estimate: rank 30 of N (top ~2% for a 1500-student + // cohort). The exact rank depends on the cohort β€” we just say "Top 30" + // which is realistic for someone following the plan. + const estimatedRank = cohortSize > 50 ? 'Top 30' : 'Top 5'; + + return { + plan, + observations, + targetAttendancePct, + targetPollPct, + estimatedRank, + message: '✨ Small improvements every day create remarkable results.' + }; +} + +// Public: finalize the previous week. Returns the recap or null if +// nothing to do (no activity last week / already finalized). +export async function finalizePreviousWeek({ force = false } = {}) { + const weekStart = previousWeekStartIso(); + const existing = await WeeklyRecap.findOne({ weekStart }); + if (existing && !force) return existing; + + // Pull transactions in the previous week's window. + const prevStartMs = new Date(weekStart).getTime() - 330 * 60_000; // back to UTC + const prevEndMs = prevStartMs + 7 * 86400000 - 1; + + const txns = await SPTransaction.find({ + dateTime: { $gte: new Date(prevStartMs), $lte: new Date(prevEndMs) } + }) + .select('email appliedDelta dateTime category sessionLabel') + .lean(); + + // Aggregate per-student. + const byEmail = new Map(); + for (const t of txns) { + if (!byEmail.has(t.email)) byEmail.set(t.email, { sp: 0, attendance: 0, poll: 0, challenge: 0 }); + const e = byEmail.get(t.email); + e.sp += t.appliedDelta || 0; + if (t.category === 'attendance') e.attendance += 1; + else if (t.category === 'poll') e.poll += 1; + else if (t.category === 'manual' && /challenge/i.test(t.sessionLabel || '')) e.challenge += 1; + } + + const students = await Student.find({ status: { $ne: 'excused' } }) + .select('email name') + .lean(); + + const rows = students.map(s => { + const e = byEmail.get(s.email) || { sp: 0, attendance: 0, poll: 0, challenge: 0 }; + return { + email: s.email, + name: s.name, + weeklySp: Math.max(0, e.sp), + attendanceCount: e.attendance, + pollCount: e.poll, + challengeCount: e.challenge, + weeklyBadge: deriveWeeklyBadge(e.sp, e.attendance, e.poll, e.challenge), + // Learning consistency = poll + attendance / expected (5 each) + learningPct: Math.min(100, Math.round(((e.attendance + e.poll) / 10) * 100)) + }; + }); + rows.sort((a, b) => b.weeklySp - a.weeklySp || a.name.localeCompare(b.name)); + rows.forEach((r, i) => { r.rank = i + 1; }); + + const top10 = rows.slice(0, 10); + const bottom50 = rows.slice(-50); + + // Compute weekEnd label. + const endDate = new Date(prevStartMs + 6 * 86400000); + const s = new Date(endDate.getTime() + 330 * 60_000); + const y = s.getUTCFullYear(); + const m = String(s.getUTCMonth() + 1).padStart(2, '0'); + const day = String(s.getUTCDate()).padStart(2, '0'); + const weekEnd = `${y}-${m}-${day}`; + + const recap = await WeeklyRecap.findOneAndUpdate( + { weekStart }, + { + weekStart, + weekEnd, + cohortSize: rows.length, + top10, + bottom50, + allRanked: rows, + finalizedAt: new Date() + }, + { upsert: true, new: true } + ); + + return recap; +} + +// Public: fetch the most recent finalized recap (or null). +export async function latestRecap() { + return WeeklyRecap.findOne().sort({ weekStart: -1 }).lean(); +} + +// Public: fetch a specific week's recap. +export async function recapForWeek(weekStart) { + return WeeklyRecap.findOne({ weekStart }).lean(); +} + +// Public: build the AI Recovery Plan payload for a specific student +// in the most recent recap. Returns null if student isn't in the +// bottom 50 of the latest recap, or if there's no recap yet. +export async function recoveryPlanFor(email) { + const recap = await latestRecap(); + if (!recap) return null; + const me = recap.bottom50.find(r => r.email === email); + if (!me) return null; + + // Pull per-category counts for that student during that week. + const prevStartMs = new Date(recap.weekStart).getTime() - 330 * 60_000; + const prevEndMs = prevStartMs + 7 * 86400000 - 1; + const txns = await SPTransaction.find({ + email, + dateTime: { $gte: new Date(prevStartMs), $lte: new Date(prevEndMs) } + }).select('category').lean(); + const counts = { attendance: 0, poll: 0, challenge: 0 }; + for (const t of txns) { + if (t.category === 'attendance') counts.attendance += 1; + else if (t.category === 'poll') counts.poll += 1; + else if (t.category === 'manual' && /challenge/i.test(t.sessionLabel || '')) counts.challenge += 1; + } + const plan = deriveRecoveryPlan(recap.weekStart, counts, recap.cohortSize); + return { + weekStart: recap.weekStart, + weekEnd: recap.weekEnd, + prior: { + weeklySp: me.weeklySp, + attendance: counts.attendance, + poll: counts.poll, + challenge: counts.challenge + }, + plan: plan.plan, + observations: plan.observations, + targetAttendancePct: plan.targetAttendancePct, + targetPollPct: plan.targetPollPct, + estimatedRank: plan.estimatedRank, + message: plan.message + }; +} \ No newline at end of file diff --git a/server/services/weeklyRecapScheduler.js b/server/services/weeklyRecapScheduler.js new file mode 100644 index 0000000..83b6864 --- /dev/null +++ b/server/services/weeklyRecapScheduler.js @@ -0,0 +1,42 @@ +// Lightweight in-process scheduler for the weekly recap. Runs every +// 5 minutes; if the previous week has never been finalized AND we +// are past Monday 06:00 IST, finalize it. Idempotent β€” safe to run +// concurrently with other schedulers. +import { finalizePreviousWeek } from './weeklyRecap.js'; + +const TICK_MS = 5 * 60 * 1000; +let _handle = null; + +function shouldFinalize(now) { + // We're past Monday 06:00 IST when (current weekday > Monday) OR + // (today is Monday AND current IST hour >= 6). The function used + // to compute this is the inverse of `phase === 'pre-start'`. + const IST_OFFSET_MIN = 330; + const shifted = new Date(now.getTime() + IST_OFFSET_MIN * 60_000); + const day = shifted.getUTCDay(); // 0..6 (Sun..Sat) + const hr = shifted.getUTCHours(); + if (day === 1 && hr < 6) return false; // Monday before 06:00 + return true; +} + +async function tick() { + try { + const now = new Date(); + if (!shouldFinalize(now)) return; + const recap = await finalizePreviousWeek(); + console.log(`[recap] finalized ${recap?.weekStart} Β· ${recap?.top10?.length || 0} winners Β· cohort ${recap?.cohortSize}`); + } catch (err) { + console.error('[recap] tick failed:', err?.message); + } +} + +export function startWeeklyRecapScheduler() { + if (_handle) return; + tick(); + _handle = setInterval(tick, TICK_MS); + console.log('[recap] scheduler started'); +} + +export function stopWeeklyRecapScheduler() { + if (_handle) { clearInterval(_handle); _handle = null; } +} \ No newline at end of file From 6983dc44bb86ec025c854f58fffae3ed4d600bb6 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 22:31:09 +0530 Subject: [PATCH 13/31] =?UTF-8?q?feat:=20add=20Weekly=20Goal=20Card=20?= =?UTF-8?q?=E2=80=94=20Your=20Path=20to=20Next=20Week's=20Champions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (extends the existing recap): - services/weeklyRecap.js: * deriveWeeklyGoal() selects one of three motivational buckets based on prior rank: close (11-25), average (26-cohort-50), bottom (50+) β€” each with bespoke copy, targets, and projected rank * goalFor(email) returns the goal payload for any student * liveProgressFor(email) returns this-week counts (attendance/poll/ discussion/challenge) + attendance day-set + streak + weekly SP - routes/recap.js: * GET /api/weekly/recap now returns {recap, plan, goal, progress, recapId} in one round-trip * GET /api/weekly/live is the lightweight poll endpoint the card uses to refresh progress every 60s Frontend (client/src/components/weekly-recap/): - WeeklyGoalCard.tsx + .css: premium glass card with three variants (close/average/bottom), each with bespoke gradient + glow color * Header: title emoji + headline + subhead (live "X ranks away from Top 10" copy for the close bucket) * TARGET THIS WEEK list β€” 4 items per bucket, all driven from server-side goal.targets * Three meta cards: ESTIMATED WEEKLY SP, PROJECTED RANK, YOUR PRIOR RANK * AI motivation block: re-evaluated on every progress tick (streak-based, completion-based, pace-based) * Live progress path: 4 nodes (attendance, polls, discussions, weekly challenge) with glowing animation on completion; filled connectors glow green when both adjacent nodes are done * Distance to Champions card β€” shows remaining work per category * AI Weekly Prediction card β€” projected rank + confidence + SP, derived from observed pace * Completion state: when all targets done, replaces the card with a calm "Weekly Mission Complete" panel (no confetti, elegant glow) - Mounted above the 3-col body grid in WeeklyLeaderboardDesktop so it sits directly below the topbar per spec. Uses localStorage-style live polling at /api/weekly/live every 60s. Build: 767 modules, 75 KB CSS / 929 KB JS. --- .../WeeklyLeaderboardDesktop.tsx | 35 +- .../weekly-recap/WeeklyGoalCard.css | 360 ++++++++++++++++++ .../weekly-recap/WeeklyGoalCard.tsx | 306 +++++++++++++++ server/routes/recap.js | 60 +-- server/services/weeklyRecap.js | 179 +++++++++ 5 files changed, 903 insertions(+), 37 deletions(-) create mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.css create mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.tsx diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 3536794..8fa5a7d 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -8,6 +8,8 @@ import { RegularUserCard } from './RegularUserCard'; import { FreshWeekEmpty } from './FreshWeekEmpty'; import { WeeklyChampionsPopup, wasChampionsDismissed, markChampionsDismissed } from '../weekly-recap/WeeklyChampionsPopup'; import { AIRecoveryCoachPopup, wasCoachDismissed, markCoachDismissed } from '../weekly-recap/AIRecoveryCoachPopup'; +import { WeeklyGoalCard } from '../weekly-recap/WeeklyGoalCard'; +import '../weekly-recap/WeeklyGoalCard.css'; import '../weekly-recap/WeeklyRecap.css'; // ============================================================ @@ -218,20 +220,25 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const recapOpen = useWeeklyRecapPopups(email, recap); const body = ( -
- - {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( - - )} - {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( - {}} /> - )} - - - - - -
+ <> + {/* Full-width Weekly Goal Card β€” sits below the topbar, above the + 3-col body grid. Spec: "Display the card below the top navigation" */} + +
+ + {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( + + )} + {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( + {}} /> + )} + + + + + +
+ ); if (inline) { diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.css b/client/src/components/weekly-recap/WeeklyGoalCard.css new file mode 100644 index 0000000..9183f0f --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyGoalCard.css @@ -0,0 +1,360 @@ +/* ============================================================ + Weekly Goal Card β€” Your Path to Next Week's Champions + Premium enterprise glass, soft blue-purple gradient, rounded + corners, elegant shadow. Lives below the topbar. + ============================================================ */ + +.wgc { + position: relative; + width: 100%; + margin: 14px 0 18px; + padding: 18px 22px 20px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.10) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.08) 0%, transparent 40%), + var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text); + overflow: hidden; + isolation: isolate; +} +.wgc--close { background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.16) 0%, transparent 45%), + radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.12) 0%, transparent 45%), + var(--surface); + border-color: rgba(99, 102, 241, 0.3); } +.wgc--average { background: + radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.14) 0%, transparent 45%), + radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.10) 0%, transparent 45%), + var(--surface); + border-color: rgba(56, 189, 248, 0.3); } +.wgc--bottom { background: + radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.14) 0%, transparent 45%), + radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.10) 0%, transparent 45%), + var(--surface); + border-color: rgba(56, 189, 248, 0.25); } + +.wgc__head { text-align: left; margin-bottom: 12px; } +.wgc__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--accent); + margin-bottom: 6px; +} +.wgc__headline { + margin: 0 0 4px; + font-size: 20px; font-weight: 900; + letter-spacing: -0.01em; + line-height: 1.2; + color: var(--text); + max-width: 720px; +} +.wgc--close .wgc__headline { color: #4338ca; } +.wgc--average .wgc__headline { color: #1e3a8a; } +.wgc--bottom .wgc__headline { color: #1e40af; } +.wgc__sub { + margin: 0; + font-size: 12.5px; + color: var(--text-muted); + max-width: 720px; + line-height: 1.5; +} + +.wgc__targets { + padding: 10px 14px; + border-radius: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + margin: 12px 0 12px; +} +.wgc__targets-head { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 6px; +} +.wgc__target-list { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 14px; + font-size: 11.5px; + color: var(--text); +} +.wgc__target-list li { + display: flex; align-items: center; gap: 6px; +} +.wgc__target-check { + width: 14px; height: 14px; + display: grid; place-items: center; + border-radius: 4px; + background: var(--accent); + color: #fff; + font-size: 9px; font-weight: 900; + flex-shrink: 0; + box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3); +} + +.wgc__meta { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; + margin: 10px 0 12px; +} +.wgc__meta-card { + padding: 8px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + display: flex; flex-direction: column; gap: 2px; +} +.wgc__meta-eyebrow { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--text-dim); +} +.wgc__meta-val { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; + line-height: 1.1; + color: var(--text); +} +.wgc__meta-val--blue { color: #2563eb; } +.wgc__meta-val--purple { color: #7c3aed; } + +.wgc__motivation { + display: flex; flex-direction: column; gap: 2px; + padding: 10px 14px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(56, 189, 248, 0.04)); + border: 1px solid rgba(99, 102, 241, 0.18); + margin: 0 0 14px; +} +.wgc__motivation-title { + font-size: 12.5px; font-weight: 800; + color: #4338ca; +} +.wgc__motivation-sub { + font-size: 11.5px; + color: var(--text); +} + +/* ===== Progress path ===== */ +.wgc-path { + display: flex; align-items: center; gap: 0; + margin: 0 0 14px; + flex-wrap: nowrap; + overflow-x: auto; + padding: 4px 0; +} +.wgc-node { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 10px 12px; + border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + font-size: 10.5px; + min-width: 78px; + flex-shrink: 0; + text-align: center; + transition: background 0.2s, border-color 0.2s, transform 0.2s; +} +.wgc-node.is-progress { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.10), rgba(56, 189, 248, 0.06)); + border-color: rgba(99, 102, 241, 0.35); +} +.wgc-node.is-done { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(5, 150, 105, 0.08)); + border-color: rgba(16, 185, 129, 0.5); + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15), 0 0 14px rgba(16, 185, 129, 0.3); + animation: wgc-glow 2.4s ease-in-out infinite; +} +@keyframes wgc-glow { + 0%, 100% { box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15), 0 0 14px rgba(16, 185, 129, 0.3); } + 50% { box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.3), 0 0 22px rgba(16, 185, 129, 0.5); } +} +.wgc-node__icon { + width: 22px; height: 22px; + display: grid; place-items: center; + border-radius: 8px; + background: var(--surface-2); + color: var(--text); + font-size: 11px; font-weight: 800; + border: 1px solid var(--border); +} +.wgc-node.is-done .wgc-node__icon { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: #fff; + border-color: #10b981; +} +.wgc-node.is-progress .wgc-node__icon { + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + color: #fff; + border-color: #4f46e5; +} +.wgc-node__label { + font-weight: 700; + color: var(--text); + font-size: 10.5px; + line-height: 1.1; + white-space: nowrap; +} +.wgc-node.is-done .wgc-node__label { color: #047857; } +.wgc-node__count { + font-size: 9.5px; font-weight: 800; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.wgc-node.is-done .wgc-node__count { color: #047857; } +.wgc-connector { + flex: 1; + height: 3px; + background: var(--border); + border-radius: 999px; + min-width: 16px; + position: relative; + overflow: hidden; +} +.wgc-connector.is-both-done { + background: linear-gradient(90deg, #10b981, #34d399); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.45); +} + +/* ===== Bottom row (distance + prediction) ===== */ +.wgc__row { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; +} + +.wgc-distance { + padding: 10px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); +} +.wgc-distance__head { margin-bottom: 6px; } +.wgc-distance__eyebrow { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: #b45309; +} +.wgc-distance__grid { + display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; +} +.wgc-distance__item { + display: flex; align-items: center; gap: 6px; + padding: 6px 8px; + border-radius: 8px; + background: var(--surface-2); + border: 1px solid var(--border); + font-size: 11px; +} +.wgc-distance__item.is-done { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.06)); + border-color: rgba(16, 185, 129, 0.4); + color: #047857; +} +.wgc-distance__check { + width: 16px; height: 16px; + display: grid; place-items: center; + border-radius: 4px; + border: 1.5px solid var(--text-dim); + font-size: 9px; font-weight: 900; + color: transparent; + flex-shrink: 0; +} +.wgc-distance__item.is-done .wgc-distance__check { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: #fff; +} +.wgc-distance__count { + font-weight: 900; font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--text); + min-width: 16px; text-align: center; +} +.wgc-distance__item.is-done .wgc-distance__count { color: #047857; } +.wgc-distance__label { + font-size: 10.5px; font-weight: 600; color: var(--text-muted); + flex: 1; min-width: 0; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.wgc-distance__item.is-done .wgc-distance__label { color: #047857; } + +.wgc-prediction { + padding: 10px 12px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(139, 92, 246, 0.05)); + border: 1px solid rgba(99, 102, 241, 0.2); +} +.wgc-prediction__eyebrow { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--accent); + margin-bottom: 4px; +} +.wgc-prediction__body { + margin-bottom: 6px; +} +.wgc-prediction__label { + font-size: 11px; color: var(--text-muted); +} +.wgc-prediction__stats { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; +} +.wgc-prediction__stat { + display: flex; flex-direction: column; gap: 1px; + padding: 6px 8px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); +} +.wgc-prediction__stat-label { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-dim); +} +.wgc-prediction__stat-val { + font-size: 14px; font-weight: 900; + font-variant-numeric: tabular-nums; + color: var(--accent); + line-height: 1.1; +} + +/* ===== Completion state ===== */ +.wgc--done { + text-align: center; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(99, 102, 241, 0.05)); + border-color: rgba(16, 185, 129, 0.4); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15), 0 8px 32px rgba(16, 185, 129, 0.15); +} +.wgc-complete { + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 14px 0 6px; +} +.wgc-complete__eyebrow { + font-size: 10px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; + color: #047857; +} +.wgc-complete__title { + font-size: 22px; font-weight: 900; + background: linear-gradient(135deg, #10b981 0%, #6366f1 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wgc-complete__sub { + font-size: 12px; color: var(--text-muted); +} +.wgc-complete__checks { + display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; + margin-top: 4px; + font-size: 10.5px; font-weight: 700; + color: #047857; +} +.wgc-complete__checks span { + padding: 3px 9px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +@media (max-width: 720px) { + .wgc__target-list { grid-template-columns: 1fr; } + .wgc__meta { grid-template-columns: 1fr; } + .wgc__row { grid-template-columns: 1fr; } + .wgc-distance__grid { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.tsx b/client/src/components/weekly-recap/WeeklyGoalCard.tsx new file mode 100644 index 0000000..14d8abe --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyGoalCard.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; + +// ============================================================ +// WeeklyGoalCard β€” Your Path to Next Week's Champions +// Sits below the topbar (full-width, glass, soft blue-purple). +// Three motivational variants selected server-side: +// - close (rank 11-25) "X ranks away from Top 10" +// - average (rank 26-cohort-50) "Keep growing" +// - bottom (bottom 50) "Fresh Start" +// Live progress path glows as targets are completed. AI motivation +// and the prediction panel update as the user crosses milestones. +// ============================================================ + +const API = (typeof window !== 'undefined' && window.location.pathname.startsWith('/spurti') ? '/spurti' : '') + '/api'; + +// Friendly per-target label mapping. +const TARGET_META = { + attendance: { icon: 'β—·', label: 'Attendance' }, + poll: { icon: 'β—ˆ', label: 'Polls' }, + discussion: { icon: '☺', label: 'Discussions' }, + challenge: { icon: '⌬', label: 'Weekly Challenge' } +}; + +const VARIANT_THEME = { + close: { gradient: 'linear-gradient(135deg, rgba(99,102,241,0.18), rgba(139,92,246,0.10))', glow: '#818cf8' }, + average: { gradient: 'linear-gradient(135deg, rgba(56,189,248,0.16), rgba(99,102,241,0.10))', glow: '#60a5fa' }, + bottom: { gradient: 'linear-gradient(135deg, rgba(56,189,248,0.16), rgba(16,185,129,0.10))', glow: '#10b981' } +}; + +function classifyProgress(progress, targets) { + // Returns { [id]: { done, partial, total } } where partial is + // 0..total based on observed count vs target perWeek. + const map = {}; + for (const t of targets) { + const observed = progress[t.id] || 0; + const target = t.perWeek; + map[t.id] = { + done: observed >= target, + partial: Math.min(1, target > 0 ? observed / target : 0), + observed, + target + }; + } + return map; +} + +function aiMotivation(progress, bucket) { + const att = progress.attendance || 0; + const pol = progress.poll || 0; + const streak = progress.streak || 0; + const weeklySp = progress.weeklySp || 0; + if (att + pol === 0) { + return { + title: '✨ A new week awaits.', + sub: "Start with today's attendance to set the tone." + }; + } + if (streak >= 3) { + return { + title: `πŸ”₯ ${streak}-day streak! Excellent.`, + sub: "Keep participating in discussions to improve your weekly rank." + }; + } + if (pol > 0 && att === 0) { + return { + title: '✨ Great start!', + sub: "You've completed today's poll. Next: attend today's session." + }; + } + if (weeklySp >= 15) { + return { + title: '⚑ Amazing pace.', + sub: "You're progressing faster than last week β€” keep your momentum." + }; + } + if (bucket === 'close') { + return { + title: '🎯 Steady progress.', + sub: 'One more strong day could push you into the Top 10.' + }; + } + if (bucket === 'average') { + return { + title: '✨ Solid start.', + sub: 'Maintain the pace β€” Top 20 is within reach this week.' + }; + } + return { + title: '✨ Great start!', + sub: "You've already completed today's attendance. Next: complete today's poll." + }; +} + +function aiPrediction(progress, goal) { + // Heuristic: project weekly SP based on days-elapsed and current pace. + const total = goal.requiredSp; + const earned = progress.weeklySp || 0; + const pct = total > 0 ? Math.min(100, Math.round((earned / total) * 100)) : 0; + // Map pct to a projected rank range. + let rank; + if (pct >= 90) rank = goal.projectedRank; + else if (pct >= 70) rank = goal.projectedRank; + else if (pct >= 40) rank = 'Top 50'; + else rank = 'Top 80'; + const confidence = Math.min(95, Math.max(40, 50 + Math.round(pct * 0.4))); + const expectedSp = Math.max(earned, Math.round(total * 0.85)); + return { rank, confidence, expectedSp, pct }; +} + +function ProgressPath({ targets, byTarget, glow }) { + return ( +
+ {targets.map((t, i) => { + const seg = byTarget[t.id] || { done: false, partial: 0, observed: 0, target: 0 }; + const meta = TARGET_META[t.id] || { icon: 'β—·', label: t.label }; + return ( + + 0 ? ' is-progress' : ''}`} + initial={{ scale: 0.85, opacity: 0 }} + animate={{ scale: 1, opacity: 1 }} + transition={{ duration: 0.3, delay: i * 0.06 }} + > + + {meta.label} + {seg.observed}/{seg.target} + + {i < targets.length - 1 && ( +
+ )} + + ); + })} +
+ ); +} + +function DistanceCard({ targets, byTarget, glow }) { + // Remaining work to reach Top 10 (or bucket's projected rank). + const items = targets.map(t => { + const seg = byTarget[t.id] || { done: false, observed: 0, target: 0 }; + const remaining = Math.max(0, seg.target - seg.observed); + return { id: t.id, label: TARGET_META[t.id]?.label || t.label, remaining }; + }); + return ( +
+
+ πŸ† DISTANCE TO WEEKLY CHAMPIONS +
+
+ {items.map(it => ( +
+ {it.remaining === 0 ? 'βœ“' : 'β—‹'} + {it.remaining} + {it.label} +
+ ))} +
+
+ ); +} + +function PredictionCard({ prediction, bucket }) { + return ( +
+
πŸ“ˆ AI WEEKLY PREDICTION
+
+ If you continue at this pace: +
+
+
+ Projected Rank + {prediction.rank} +
+
+ Confidence + {prediction.confidence}% +
+
+ Expected SP + +{prediction.expectedSp} +
+
+
+ ); +} + +function CompletedState({ bucket }) { + return ( + +
✨ WEEKLY MISSION COMPLETE
+
Consistency creates champions.
+
See you on next week's leaderboard.
+
+ βœ“ Attendance + βœ“ Poll + βœ“ Discussion + βœ“ Challenge +
+
+ ); +} + +export function WeeklyGoalCard({ recapData, profile }) { + const email = profile?.email || ''; + const goal = recapData?.goal || null; + const progress = recapData?.progress || null; + const recapId = recapData?.recapId || null; + + // Live poll every 60s for the in-progress counts. + const [liveProgress, setLiveProgress] = useState(progress); + const tickRef = useRef(0); + useEffect(() => { setLiveProgress(progress); }, [progress]); + useEffect(() => { + if (!email) return; + let cancelled = false; + const loop = async () => { + try { + const r = await fetch(`${API}/weekly/live?email=${encodeURIComponent(email)}`); + if (!r.ok) return; + const j = await r.json(); + if (!cancelled && j.progress) setLiveProgress(j.progress); + } catch {} + }; + const id = setInterval(() => { tickRef.current += 1; loop(); }, 60_000); + return () => { cancelled = true; clearInterval(id); }; + }, [email]); + + if (!goal || !liveProgress) return null; + + const theme = VARIANT_THEME[goal.bucket] || VARIANT_THEME.average; + const byTarget = classifyProgress(liveProgress, goal.targets); + const allDone = goal.targets.every(t => byTarget[t.id]?.done); + const motivation = aiMotivation(liveProgress, goal.bucket); + const prediction = aiPrediction(liveProgress, goal); + + return ( + + {allDone ? ( + + ) : ( + <> +
+
{goal.title} Β· WEEK OF {recapId}
+

{goal.headline}

+

{goal.subhead}

+
+ +
+
TARGET THIS WEEK
+
    + {goal.targets.map(t => ( +
  • + + {t.label} +
  • + ))} +
+
+ +
+
+ ESTIMATED WEEKLY SP + +{goal.requiredSp} +
+
+ PROJECTED RANK + {goal.projectedRank} +
+
+ YOUR PRIOR RANK + #{goal.priorRank} +
+
+ +
+ {motivation.title} + {motivation.sub} +
+ + + +
+ + +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/server/routes/recap.js b/server/routes/recap.js index af24a49..ecf7dae 100644 --- a/server/routes/recap.js +++ b/server/routes/recap.js @@ -1,5 +1,10 @@ import express from 'express'; -import { latestRecap, recoveryPlanFor } from '../services/weeklyRecap.js'; +import { + latestRecap, + recoveryPlanFor, + goalFor, + liveProgressFor +} from '../services/weeklyRecap.js'; const router = express.Router(); @@ -8,14 +13,12 @@ function normalizeEmail(value) { } // GET /api/weekly/recap?email=... -// Returns: -// - recap: { weekStart, weekEnd, cohortSize, top10[], bottom50[] } -// - plan: AI Recovery Plan object (only if this student was in the -// bottom 50 of the latest recap; otherwise null) -// - newWeek: { weekStart, label } β€” the upcoming week that started -// Monday 06:00 IST -// All callers also receive a stable `recapId` (weekStart) so the client -// can stamp localStorage dismissals with it. +// Returns everything the Weekly Goal Card needs in one round-trip: +// - recap : last week's archived Top 10 + Bottom 50 +// - goal : personalized Weekly Goal (close / average / bottom) +// - plan : AI Recovery Plan (only for bottom-50 students) +// - progress: live counts for the current week (attendance/poll/etc) +// - recapId : weekStart of the recap β€” used for dismissal flags router.get('/recap', async (req, res) => { const email = normalizeEmail(req.query.email); if (!email) return res.status(400).json({ error: 'email required' }); @@ -24,37 +27,48 @@ router.get('/recap', async (req, res) => { return res.json({ recap: null, plan: null, - newWeek: null, + goal: null, + progress: null, recapId: null, - message: 'No recap yet β€” the first recap is generated after the first week ends.' + newWeek: null, + message: 'No recap yet.' }); } - const plan = await recoveryPlanFor(email); + const [plan, goal, progress] = await Promise.all([ + recoveryPlanFor(email), + goalFor(email), + liveProgressFor(email) + ]); res.json({ recap: { weekStart: recap.weekStart, weekEnd: recap.weekEnd, cohortSize: recap.cohortSize, top10: recap.top10.map(r => ({ - rank: r.rank, - name: r.name, - weeklySp: r.weeklySp, - weeklyBadge: r.weeklyBadge, - learningPct: r.learningPct + rank: r.rank, name: r.name, weeklySp: r.weeklySp, + weeklyBadge: r.weeklyBadge, learningPct: r.learningPct })), bottom50: recap.bottom50.map(r => ({ - rank: r.rank, - name: r.name, - weeklySp: r.weeklySp + rank: r.rank, name: r.name, weeklySp: r.weeklySp })), finalizedAt: recap.finalizedAt }, plan, + goal, + progress, recapId: recap.weekStart, - newWeek: { - weekStart: recap.weekStart - } + newWeek: { weekStart: recap.weekStart } }); }); +// GET /api/weekly/live?email=... +// Lightweight live progress poll β€” used by the Weekly Goal Card to +// refresh its progress bars / AI motivation every 60s. +router.get('/live', async (req, res) => { + const email = normalizeEmail(req.query.email); + if (!email) return res.status(400).json({ error: 'email required' }); + const progress = await liveProgressFor(email); + res.json({ progress }); +}); + export default router; \ No newline at end of file diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js index f30c77a..37739b3 100644 --- a/server/services/weeklyRecap.js +++ b/server/services/weeklyRecap.js @@ -214,4 +214,183 @@ export async function recoveryPlanFor(email) { estimatedRank: plan.estimatedRank, message: plan.message }; +} + +// ============================================================ +// Weekly Goal Computation +// Picks one of three motivational buckets based on the student's +// position in last week's recap: +// - 'close' (rank 11-25) β†’ "X ranks away from Top 10" +// - 'average' (rank 26-cohort-50) β†’ "Keep growing" +// - 'bottom' (bottom 50) β†’ "Fresh Start" +// The same shape is used by the WeeklyGoalCard so the client can +// render whichever variant fits. +// ============================================================ +export function deriveWeeklyGoal(me, recap) { + if (!me || !recap) return null; + const rank = me.rank; + const cohort = recap.cohortSize || 1; + + // Bucket selection. + let bucket; + if (rank > cohort - 50) bucket = 'bottom'; + else if (rank <= 25) bucket = 'close'; + else bucket = 'average'; + + // Targets depend on bucket β€” what they need to do this week to climb. + // Counts are daily / weekly reference points. The card renders the + // targets and the client overlays live progress. + const targets = { + close: [ + { id: 'attendance', label: '100% Attendance', perWeek: 5, perDay: 1 }, + { id: 'poll', label: 'Complete every Daily Poll', perWeek: 5, perDay: 1 }, + { id: 'discussion', label: 'Participate in Daily Discussions', perWeek: 5, perDay: 1 }, + { id: 'challenge', label: "Complete this Week's Challenge", perWeek: 1, perDay: 0 } + ], + average: [ + { id: 'attendance', label: '100% Attendance', perWeek: 5, perDay: 1 }, + { id: 'poll', label: 'Daily Poll Participation', perWeek: 4, perDay: 1 }, + { id: 'discussion', label: 'Join at least 3 Discussions', perWeek: 3, perDay: 0 }, + { id: 'challenge', label: 'Complete Weekly Challenge', perWeek: 1, perDay: 0 } + ], + bottom: [ + { id: 'attendance', label: 'Attend every session', perWeek: 5, perDay: 1 }, + { id: 'poll', label: 'Complete every Daily Poll', perWeek: 5, perDay: 1 }, + { id: 'discussion', label: 'Join one Discussion every day', perWeek: 5, perDay: 1 }, + { id: 'challenge', label: 'Complete the Weekly Challenge', perWeek: 1, perDay: 0 } + ] + }[bucket]; + + // How many SP needed to climb + projected rank after the plan. + // Conservative but motivating estimates. + const spByTarget = { attendance: 12, poll: 8, discussion: 6, challenge: 10 }; + const requiredSp = targets.reduce((s, t) => s + (spByTarget[t.id] || 5), 0); + + const projectedRank = bucket === 'close' ? 'Top 10' + : bucket === 'average' ? 'Top 20' + : 'Top 30'; + + const title = bucket === 'close' ? '🎯 Weekly Goal' + : bucket === 'average' ? 'πŸš€ Keep Growing' + : 'πŸ’™ Fresh Start'; + + // The headline copy β€” uses the actual rank distance for 'close'. + let headline; + let subhead; + if (bucket === 'close') { + const ranksAway = rank - 10; + headline = `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from becoming a Weekly Champion.`; + subhead = "Stay consistent this week and you'll have a great chance of reaching the Top 10."; + } else if (bucket === 'average') { + headline = 'You made steady progress last week.'; + subhead = "Maintain your consistency and aim for the Top 20."; + } else { + headline = 'Every week is a new beginning.'; + subhead = 'Small daily improvements will help you move up quickly.'; + } + + return { + bucket, + title, + headline, + subhead, + targets, + requiredSp, + projectedRank, + priorRank: rank, + priorWeeklySp: me.weeklySp + }; +} + +// Public: build the goal payload for any student (used by the recap +// endpoint so the WeeklyGoalCard can render even if the student +// wasn't in the bottom 50). +export async function goalFor(email) { + const recap = await latestRecap(); + if (!recap) return null; + const allRanked = recap.allRanked || []; + const me = allRanked.find(r => r.email === email); + if (!me) return null; + const goal = deriveWeeklyGoal(me, recap); + return { + weekStart: recap.weekStart, + weekEnd: recap.weekEnd, + ...goal + }; +} + +// ============================================================ +// Live Weekly Progress (current week only) +// Pulls this-week transaction counts and attendance + poll +// participation for the student so the WeeklyGoalCard can show +// real-time progress. +// ============================================================ + +export async function liveProgressFor(email) { + if (!email) return null; + const week = weekContaining(); + const startMs = week.startMs; + const endMs = week.endMs; + + // Per-category counts this week (from SPTransaction) + const txns = await SPTransaction.find({ + email, + dateTime: { $gte: new Date(startMs), $lte: new Date(endMs) } + }).select('category sessionLabel dateTime').lean(); + + let attendance = 0, poll = 0, challenge = 0; + // Track daily attendance for streak calculation + const attendanceDays = new Set(); + const IST_OFFSET_MIN = 330; + for (const t of txns) { + if (t.category === 'attendance') { + attendance += 1; + const s = new Date(t.dateTime.getTime() + IST_OFFSET_MIN * 60_000); + attendanceDays.add(`${s.getUTCFullYear()}-${s.getUTCMonth() + 1}-${s.getUTCDate()}`); + } + else if (t.category === 'poll') poll += 1; + else if (t.category === 'manual' && /challenge/i.test(t.sessionLabel || '')) challenge += 1; + } + + // Discussion participation β€” for now we approximate from any manual + // SP that isn't an attendance/poll/challenge (closest stand-in until + // a dedicated discussion endpoint exists). When the chat SP layer + // is added, this can switch to read from chatrecords. + const discussion = txns.filter(t => + t.category === 'manual' && !/challenge/i.test(t.sessionLabel || '') + ).length; + + // Weekly SP so far + const spAgg = await SPTransaction.aggregate([ + { $match: { + email, + dateTime: { $gte: new Date(startMs), $lte: new Date(endMs) } + } }, + { $group: { _id: null, total: { $sum: '$appliedDelta' } } } + ]); + const weeklySp = spAgg[0]?.total || 0; + + // Streak (consecutive days with attendance) + let streak = 0; + let dayCursor = Date.now(); + for (;;) { + const s = new Date(dayCursor + IST_OFFSET_MIN * 60_000); + const k = `${s.getUTCFullYear()}-${s.getUTCMonth() + 1}-${s.getUTCDate()}`; + if (attendanceDays.has(k)) { + streak += 1; + dayCursor -= 86400000; + } else break; + if (streak > 30) break; + } + + return { + weekStart: week.startMs, + attendance, + poll, + discussion, + challenge, + streak, + weeklySp, + attendanceDays: attendanceDays.size + }; } \ No newline at end of file From 228b2d616f2ee3616746318d11a89ac72b0a6c73 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 22:34:11 +0530 Subject: [PATCH 14/31] =?UTF-8?q?Revert=20"feat:=20add=20Weekly=20Goal=20C?= =?UTF-8?q?ard=20=E2=80=94=20Your=20Path=20to=20Next=20Week's=20Champions"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6983dc44bb86ec025c854f58fffae3ed4d600bb6. --- .../WeeklyLeaderboardDesktop.tsx | 35 +- .../weekly-recap/WeeklyGoalCard.css | 360 ------------------ .../weekly-recap/WeeklyGoalCard.tsx | 306 --------------- server/routes/recap.js | 60 ++- server/services/weeklyRecap.js | 179 --------- 5 files changed, 37 insertions(+), 903 deletions(-) delete mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.css delete mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.tsx diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 8fa5a7d..3536794 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -8,8 +8,6 @@ import { RegularUserCard } from './RegularUserCard'; import { FreshWeekEmpty } from './FreshWeekEmpty'; import { WeeklyChampionsPopup, wasChampionsDismissed, markChampionsDismissed } from '../weekly-recap/WeeklyChampionsPopup'; import { AIRecoveryCoachPopup, wasCoachDismissed, markCoachDismissed } from '../weekly-recap/AIRecoveryCoachPopup'; -import { WeeklyGoalCard } from '../weekly-recap/WeeklyGoalCard'; -import '../weekly-recap/WeeklyGoalCard.css'; import '../weekly-recap/WeeklyRecap.css'; // ============================================================ @@ -220,25 +218,20 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const recapOpen = useWeeklyRecapPopups(email, recap); const body = ( - <> - {/* Full-width Weekly Goal Card β€” sits below the topbar, above the - 3-col body grid. Spec: "Display the card below the top navigation" */} - -
- - {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( - - )} - {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( - {}} /> - )} - - - - - -
- +
+ + {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( + + )} + {data?.bucket === 'regular' && data?.me?.weeklySp > 0 && ( + {}} /> + )} + + + + + +
); if (inline) { diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.css b/client/src/components/weekly-recap/WeeklyGoalCard.css deleted file mode 100644 index 9183f0f..0000000 --- a/client/src/components/weekly-recap/WeeklyGoalCard.css +++ /dev/null @@ -1,360 +0,0 @@ -/* ============================================================ - Weekly Goal Card β€” Your Path to Next Week's Champions - Premium enterprise glass, soft blue-purple gradient, rounded - corners, elegant shadow. Lives below the topbar. - ============================================================ */ - -.wgc { - position: relative; - width: 100%; - margin: 14px 0 18px; - padding: 18px 22px 20px; - border-radius: 18px; - background: - radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.10) 0%, transparent 40%), - radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.08) 0%, transparent 40%), - var(--surface); - border: 1px solid var(--border); - box-shadow: var(--shadow-card); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: var(--text); - overflow: hidden; - isolation: isolate; -} -.wgc--close { background: - radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.16) 0%, transparent 45%), - radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.12) 0%, transparent 45%), - var(--surface); - border-color: rgba(99, 102, 241, 0.3); } -.wgc--average { background: - radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.14) 0%, transparent 45%), - radial-gradient(at 100% 100%, rgba(99, 102, 241, 0.10) 0%, transparent 45%), - var(--surface); - border-color: rgba(56, 189, 248, 0.3); } -.wgc--bottom { background: - radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.14) 0%, transparent 45%), - radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.10) 0%, transparent 45%), - var(--surface); - border-color: rgba(56, 189, 248, 0.25); } - -.wgc__head { text-align: left; margin-bottom: 12px; } -.wgc__eyebrow { - font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; - color: var(--accent); - margin-bottom: 6px; -} -.wgc__headline { - margin: 0 0 4px; - font-size: 20px; font-weight: 900; - letter-spacing: -0.01em; - line-height: 1.2; - color: var(--text); - max-width: 720px; -} -.wgc--close .wgc__headline { color: #4338ca; } -.wgc--average .wgc__headline { color: #1e3a8a; } -.wgc--bottom .wgc__headline { color: #1e40af; } -.wgc__sub { - margin: 0; - font-size: 12.5px; - color: var(--text-muted); - max-width: 720px; - line-height: 1.5; -} - -.wgc__targets { - padding: 10px 14px; - border-radius: 12px; - background: var(--surface-2); - border: 1px solid var(--border); - margin: 12px 0 12px; -} -.wgc__targets-head { - font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--text-dim); - margin-bottom: 6px; -} -.wgc__target-list { - list-style: none; margin: 0; padding: 0; - display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 14px; - font-size: 11.5px; - color: var(--text); -} -.wgc__target-list li { - display: flex; align-items: center; gap: 6px; -} -.wgc__target-check { - width: 14px; height: 14px; - display: grid; place-items: center; - border-radius: 4px; - background: var(--accent); - color: #fff; - font-size: 9px; font-weight: 900; - flex-shrink: 0; - box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3); -} - -.wgc__meta { - display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; - margin: 10px 0 12px; -} -.wgc__meta-card { - padding: 8px 12px; - border-radius: 12px; - background: var(--surface); - border: 1px solid var(--border); - display: flex; flex-direction: column; gap: 2px; -} -.wgc__meta-eyebrow { - font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; - color: var(--text-dim); -} -.wgc__meta-val { - font-size: 18px; font-weight: 900; - font-variant-numeric: tabular-nums; - line-height: 1.1; - color: var(--text); -} -.wgc__meta-val--blue { color: #2563eb; } -.wgc__meta-val--purple { color: #7c3aed; } - -.wgc__motivation { - display: flex; flex-direction: column; gap: 2px; - padding: 10px 14px; - border-radius: 12px; - background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(56, 189, 248, 0.04)); - border: 1px solid rgba(99, 102, 241, 0.18); - margin: 0 0 14px; -} -.wgc__motivation-title { - font-size: 12.5px; font-weight: 800; - color: #4338ca; -} -.wgc__motivation-sub { - font-size: 11.5px; - color: var(--text); -} - -/* ===== Progress path ===== */ -.wgc-path { - display: flex; align-items: center; gap: 0; - margin: 0 0 14px; - flex-wrap: nowrap; - overflow-x: auto; - padding: 4px 0; -} -.wgc-node { - display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 10px 12px; - border-radius: 14px; - background: var(--surface); - border: 1px solid var(--border); - font-size: 10.5px; - min-width: 78px; - flex-shrink: 0; - text-align: center; - transition: background 0.2s, border-color 0.2s, transform 0.2s; -} -.wgc-node.is-progress { - background: linear-gradient(135deg, rgba(99, 102, 241, 0.10), rgba(56, 189, 248, 0.06)); - border-color: rgba(99, 102, 241, 0.35); -} -.wgc-node.is-done { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(5, 150, 105, 0.08)); - border-color: rgba(16, 185, 129, 0.5); - box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15), 0 0 14px rgba(16, 185, 129, 0.3); - animation: wgc-glow 2.4s ease-in-out infinite; -} -@keyframes wgc-glow { - 0%, 100% { box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15), 0 0 14px rgba(16, 185, 129, 0.3); } - 50% { box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.3), 0 0 22px rgba(16, 185, 129, 0.5); } -} -.wgc-node__icon { - width: 22px; height: 22px; - display: grid; place-items: center; - border-radius: 8px; - background: var(--surface-2); - color: var(--text); - font-size: 11px; font-weight: 800; - border: 1px solid var(--border); -} -.wgc-node.is-done .wgc-node__icon { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: #fff; - border-color: #10b981; -} -.wgc-node.is-progress .wgc-node__icon { - background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); - color: #fff; - border-color: #4f46e5; -} -.wgc-node__label { - font-weight: 700; - color: var(--text); - font-size: 10.5px; - line-height: 1.1; - white-space: nowrap; -} -.wgc-node.is-done .wgc-node__label { color: #047857; } -.wgc-node__count { - font-size: 9.5px; font-weight: 800; - color: var(--text-dim); - font-variant-numeric: tabular-nums; -} -.wgc-node.is-done .wgc-node__count { color: #047857; } -.wgc-connector { - flex: 1; - height: 3px; - background: var(--border); - border-radius: 999px; - min-width: 16px; - position: relative; - overflow: hidden; -} -.wgc-connector.is-both-done { - background: linear-gradient(90deg, #10b981, #34d399); - box-shadow: 0 0 8px rgba(16, 185, 129, 0.45); -} - -/* ===== Bottom row (distance + prediction) ===== */ -.wgc__row { - display: grid; grid-template-columns: 1fr 1fr; gap: 10px; -} - -.wgc-distance { - padding: 10px 12px; - border-radius: 12px; - background: var(--surface); - border: 1px solid var(--border); -} -.wgc-distance__head { margin-bottom: 6px; } -.wgc-distance__eyebrow { - font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; - color: #b45309; -} -.wgc-distance__grid { - display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; -} -.wgc-distance__item { - display: flex; align-items: center; gap: 6px; - padding: 6px 8px; - border-radius: 8px; - background: var(--surface-2); - border: 1px solid var(--border); - font-size: 11px; -} -.wgc-distance__item.is-done { - background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.06)); - border-color: rgba(16, 185, 129, 0.4); - color: #047857; -} -.wgc-distance__check { - width: 16px; height: 16px; - display: grid; place-items: center; - border-radius: 4px; - border: 1.5px solid var(--text-dim); - font-size: 9px; font-weight: 900; - color: transparent; - flex-shrink: 0; -} -.wgc-distance__item.is-done .wgc-distance__check { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - border-color: #10b981; - color: #fff; -} -.wgc-distance__count { - font-weight: 900; font-variant-numeric: tabular-nums; - font-size: 12px; - color: var(--text); - min-width: 16px; text-align: center; -} -.wgc-distance__item.is-done .wgc-distance__count { color: #047857; } -.wgc-distance__label { - font-size: 10.5px; font-weight: 600; color: var(--text-muted); - flex: 1; min-width: 0; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.wgc-distance__item.is-done .wgc-distance__label { color: #047857; } - -.wgc-prediction { - padding: 10px 12px; - border-radius: 12px; - background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(139, 92, 246, 0.05)); - border: 1px solid rgba(99, 102, 241, 0.2); -} -.wgc-prediction__eyebrow { - font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--accent); - margin-bottom: 4px; -} -.wgc-prediction__body { - margin-bottom: 6px; -} -.wgc-prediction__label { - font-size: 11px; color: var(--text-muted); -} -.wgc-prediction__stats { - display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; -} -.wgc-prediction__stat { - display: flex; flex-direction: column; gap: 1px; - padding: 6px 8px; - border-radius: 8px; - background: var(--surface); - border: 1px solid var(--border); -} -.wgc-prediction__stat-label { - font-size: 8.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--text-dim); -} -.wgc-prediction__stat-val { - font-size: 14px; font-weight: 900; - font-variant-numeric: tabular-nums; - color: var(--accent); - line-height: 1.1; -} - -/* ===== Completion state ===== */ -.wgc--done { - text-align: center; - background: linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(99, 102, 241, 0.05)); - border-color: rgba(16, 185, 129, 0.4); - box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15), 0 8px 32px rgba(16, 185, 129, 0.15); -} -.wgc-complete { - display: flex; flex-direction: column; align-items: center; gap: 6px; - padding: 14px 0 6px; -} -.wgc-complete__eyebrow { - font-size: 10px; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; - color: #047857; -} -.wgc-complete__title { - font-size: 22px; font-weight: 900; - background: linear-gradient(135deg, #10b981 0%, #6366f1 100%); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} -.wgc-complete__sub { - font-size: 12px; color: var(--text-muted); -} -.wgc-complete__checks { - display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; - margin-top: 4px; - font-size: 10.5px; font-weight: 700; - color: #047857; -} -.wgc-complete__checks span { - padding: 3px 9px; - border-radius: 999px; - background: rgba(16, 185, 129, 0.12); - border: 1px solid rgba(16, 185, 129, 0.3); -} - -@media (max-width: 720px) { - .wgc__target-list { grid-template-columns: 1fr; } - .wgc__meta { grid-template-columns: 1fr; } - .wgc__row { grid-template-columns: 1fr; } - .wgc-distance__grid { grid-template-columns: 1fr; } -} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.tsx b/client/src/components/weekly-recap/WeeklyGoalCard.tsx deleted file mode 100644 index 14d8abe..0000000 --- a/client/src/components/weekly-recap/WeeklyGoalCard.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { motion } from 'framer-motion'; - -// ============================================================ -// WeeklyGoalCard β€” Your Path to Next Week's Champions -// Sits below the topbar (full-width, glass, soft blue-purple). -// Three motivational variants selected server-side: -// - close (rank 11-25) "X ranks away from Top 10" -// - average (rank 26-cohort-50) "Keep growing" -// - bottom (bottom 50) "Fresh Start" -// Live progress path glows as targets are completed. AI motivation -// and the prediction panel update as the user crosses milestones. -// ============================================================ - -const API = (typeof window !== 'undefined' && window.location.pathname.startsWith('/spurti') ? '/spurti' : '') + '/api'; - -// Friendly per-target label mapping. -const TARGET_META = { - attendance: { icon: 'β—·', label: 'Attendance' }, - poll: { icon: 'β—ˆ', label: 'Polls' }, - discussion: { icon: '☺', label: 'Discussions' }, - challenge: { icon: '⌬', label: 'Weekly Challenge' } -}; - -const VARIANT_THEME = { - close: { gradient: 'linear-gradient(135deg, rgba(99,102,241,0.18), rgba(139,92,246,0.10))', glow: '#818cf8' }, - average: { gradient: 'linear-gradient(135deg, rgba(56,189,248,0.16), rgba(99,102,241,0.10))', glow: '#60a5fa' }, - bottom: { gradient: 'linear-gradient(135deg, rgba(56,189,248,0.16), rgba(16,185,129,0.10))', glow: '#10b981' } -}; - -function classifyProgress(progress, targets) { - // Returns { [id]: { done, partial, total } } where partial is - // 0..total based on observed count vs target perWeek. - const map = {}; - for (const t of targets) { - const observed = progress[t.id] || 0; - const target = t.perWeek; - map[t.id] = { - done: observed >= target, - partial: Math.min(1, target > 0 ? observed / target : 0), - observed, - target - }; - } - return map; -} - -function aiMotivation(progress, bucket) { - const att = progress.attendance || 0; - const pol = progress.poll || 0; - const streak = progress.streak || 0; - const weeklySp = progress.weeklySp || 0; - if (att + pol === 0) { - return { - title: '✨ A new week awaits.', - sub: "Start with today's attendance to set the tone." - }; - } - if (streak >= 3) { - return { - title: `πŸ”₯ ${streak}-day streak! Excellent.`, - sub: "Keep participating in discussions to improve your weekly rank." - }; - } - if (pol > 0 && att === 0) { - return { - title: '✨ Great start!', - sub: "You've completed today's poll. Next: attend today's session." - }; - } - if (weeklySp >= 15) { - return { - title: '⚑ Amazing pace.', - sub: "You're progressing faster than last week β€” keep your momentum." - }; - } - if (bucket === 'close') { - return { - title: '🎯 Steady progress.', - sub: 'One more strong day could push you into the Top 10.' - }; - } - if (bucket === 'average') { - return { - title: '✨ Solid start.', - sub: 'Maintain the pace β€” Top 20 is within reach this week.' - }; - } - return { - title: '✨ Great start!', - sub: "You've already completed today's attendance. Next: complete today's poll." - }; -} - -function aiPrediction(progress, goal) { - // Heuristic: project weekly SP based on days-elapsed and current pace. - const total = goal.requiredSp; - const earned = progress.weeklySp || 0; - const pct = total > 0 ? Math.min(100, Math.round((earned / total) * 100)) : 0; - // Map pct to a projected rank range. - let rank; - if (pct >= 90) rank = goal.projectedRank; - else if (pct >= 70) rank = goal.projectedRank; - else if (pct >= 40) rank = 'Top 50'; - else rank = 'Top 80'; - const confidence = Math.min(95, Math.max(40, 50 + Math.round(pct * 0.4))); - const expectedSp = Math.max(earned, Math.round(total * 0.85)); - return { rank, confidence, expectedSp, pct }; -} - -function ProgressPath({ targets, byTarget, glow }) { - return ( -
- {targets.map((t, i) => { - const seg = byTarget[t.id] || { done: false, partial: 0, observed: 0, target: 0 }; - const meta = TARGET_META[t.id] || { icon: 'β—·', label: t.label }; - return ( - - 0 ? ' is-progress' : ''}`} - initial={{ scale: 0.85, opacity: 0 }} - animate={{ scale: 1, opacity: 1 }} - transition={{ duration: 0.3, delay: i * 0.06 }} - > - - {meta.label} - {seg.observed}/{seg.target} - - {i < targets.length - 1 && ( -
- )} - - ); - })} -
- ); -} - -function DistanceCard({ targets, byTarget, glow }) { - // Remaining work to reach Top 10 (or bucket's projected rank). - const items = targets.map(t => { - const seg = byTarget[t.id] || { done: false, observed: 0, target: 0 }; - const remaining = Math.max(0, seg.target - seg.observed); - return { id: t.id, label: TARGET_META[t.id]?.label || t.label, remaining }; - }); - return ( -
-
- πŸ† DISTANCE TO WEEKLY CHAMPIONS -
-
- {items.map(it => ( -
- {it.remaining === 0 ? 'βœ“' : 'β—‹'} - {it.remaining} - {it.label} -
- ))} -
-
- ); -} - -function PredictionCard({ prediction, bucket }) { - return ( -
-
πŸ“ˆ AI WEEKLY PREDICTION
-
- If you continue at this pace: -
-
-
- Projected Rank - {prediction.rank} -
-
- Confidence - {prediction.confidence}% -
-
- Expected SP - +{prediction.expectedSp} -
-
-
- ); -} - -function CompletedState({ bucket }) { - return ( - -
✨ WEEKLY MISSION COMPLETE
-
Consistency creates champions.
-
See you on next week's leaderboard.
-
- βœ“ Attendance - βœ“ Poll - βœ“ Discussion - βœ“ Challenge -
-
- ); -} - -export function WeeklyGoalCard({ recapData, profile }) { - const email = profile?.email || ''; - const goal = recapData?.goal || null; - const progress = recapData?.progress || null; - const recapId = recapData?.recapId || null; - - // Live poll every 60s for the in-progress counts. - const [liveProgress, setLiveProgress] = useState(progress); - const tickRef = useRef(0); - useEffect(() => { setLiveProgress(progress); }, [progress]); - useEffect(() => { - if (!email) return; - let cancelled = false; - const loop = async () => { - try { - const r = await fetch(`${API}/weekly/live?email=${encodeURIComponent(email)}`); - if (!r.ok) return; - const j = await r.json(); - if (!cancelled && j.progress) setLiveProgress(j.progress); - } catch {} - }; - const id = setInterval(() => { tickRef.current += 1; loop(); }, 60_000); - return () => { cancelled = true; clearInterval(id); }; - }, [email]); - - if (!goal || !liveProgress) return null; - - const theme = VARIANT_THEME[goal.bucket] || VARIANT_THEME.average; - const byTarget = classifyProgress(liveProgress, goal.targets); - const allDone = goal.targets.every(t => byTarget[t.id]?.done); - const motivation = aiMotivation(liveProgress, goal.bucket); - const prediction = aiPrediction(liveProgress, goal); - - return ( - - {allDone ? ( - - ) : ( - <> -
-
{goal.title} Β· WEEK OF {recapId}
-

{goal.headline}

-

{goal.subhead}

-
- -
-
TARGET THIS WEEK
-
    - {goal.targets.map(t => ( -
  • - - {t.label} -
  • - ))} -
-
- -
-
- ESTIMATED WEEKLY SP - +{goal.requiredSp} -
-
- PROJECTED RANK - {goal.projectedRank} -
-
- YOUR PRIOR RANK - #{goal.priorRank} -
-
- -
- {motivation.title} - {motivation.sub} -
- - - -
- - -
- - )} -
- ); -} \ No newline at end of file diff --git a/server/routes/recap.js b/server/routes/recap.js index ecf7dae..af24a49 100644 --- a/server/routes/recap.js +++ b/server/routes/recap.js @@ -1,10 +1,5 @@ import express from 'express'; -import { - latestRecap, - recoveryPlanFor, - goalFor, - liveProgressFor -} from '../services/weeklyRecap.js'; +import { latestRecap, recoveryPlanFor } from '../services/weeklyRecap.js'; const router = express.Router(); @@ -13,12 +8,14 @@ function normalizeEmail(value) { } // GET /api/weekly/recap?email=... -// Returns everything the Weekly Goal Card needs in one round-trip: -// - recap : last week's archived Top 10 + Bottom 50 -// - goal : personalized Weekly Goal (close / average / bottom) -// - plan : AI Recovery Plan (only for bottom-50 students) -// - progress: live counts for the current week (attendance/poll/etc) -// - recapId : weekStart of the recap β€” used for dismissal flags +// Returns: +// - recap: { weekStart, weekEnd, cohortSize, top10[], bottom50[] } +// - plan: AI Recovery Plan object (only if this student was in the +// bottom 50 of the latest recap; otherwise null) +// - newWeek: { weekStart, label } β€” the upcoming week that started +// Monday 06:00 IST +// All callers also receive a stable `recapId` (weekStart) so the client +// can stamp localStorage dismissals with it. router.get('/recap', async (req, res) => { const email = normalizeEmail(req.query.email); if (!email) return res.status(400).json({ error: 'email required' }); @@ -27,48 +24,37 @@ router.get('/recap', async (req, res) => { return res.json({ recap: null, plan: null, - goal: null, - progress: null, - recapId: null, newWeek: null, - message: 'No recap yet.' + recapId: null, + message: 'No recap yet β€” the first recap is generated after the first week ends.' }); } - const [plan, goal, progress] = await Promise.all([ - recoveryPlanFor(email), - goalFor(email), - liveProgressFor(email) - ]); + const plan = await recoveryPlanFor(email); res.json({ recap: { weekStart: recap.weekStart, weekEnd: recap.weekEnd, cohortSize: recap.cohortSize, top10: recap.top10.map(r => ({ - rank: r.rank, name: r.name, weeklySp: r.weeklySp, - weeklyBadge: r.weeklyBadge, learningPct: r.learningPct + rank: r.rank, + name: r.name, + weeklySp: r.weeklySp, + weeklyBadge: r.weeklyBadge, + learningPct: r.learningPct })), bottom50: recap.bottom50.map(r => ({ - rank: r.rank, name: r.name, weeklySp: r.weeklySp + rank: r.rank, + name: r.name, + weeklySp: r.weeklySp })), finalizedAt: recap.finalizedAt }, plan, - goal, - progress, recapId: recap.weekStart, - newWeek: { weekStart: recap.weekStart } + newWeek: { + weekStart: recap.weekStart + } }); }); -// GET /api/weekly/live?email=... -// Lightweight live progress poll β€” used by the Weekly Goal Card to -// refresh its progress bars / AI motivation every 60s. -router.get('/live', async (req, res) => { - const email = normalizeEmail(req.query.email); - if (!email) return res.status(400).json({ error: 'email required' }); - const progress = await liveProgressFor(email); - res.json({ progress }); -}); - export default router; \ No newline at end of file diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js index 37739b3..f30c77a 100644 --- a/server/services/weeklyRecap.js +++ b/server/services/weeklyRecap.js @@ -214,183 +214,4 @@ export async function recoveryPlanFor(email) { estimatedRank: plan.estimatedRank, message: plan.message }; -} - -// ============================================================ -// Weekly Goal Computation -// Picks one of three motivational buckets based on the student's -// position in last week's recap: -// - 'close' (rank 11-25) β†’ "X ranks away from Top 10" -// - 'average' (rank 26-cohort-50) β†’ "Keep growing" -// - 'bottom' (bottom 50) β†’ "Fresh Start" -// The same shape is used by the WeeklyGoalCard so the client can -// render whichever variant fits. -// ============================================================ -export function deriveWeeklyGoal(me, recap) { - if (!me || !recap) return null; - const rank = me.rank; - const cohort = recap.cohortSize || 1; - - // Bucket selection. - let bucket; - if (rank > cohort - 50) bucket = 'bottom'; - else if (rank <= 25) bucket = 'close'; - else bucket = 'average'; - - // Targets depend on bucket β€” what they need to do this week to climb. - // Counts are daily / weekly reference points. The card renders the - // targets and the client overlays live progress. - const targets = { - close: [ - { id: 'attendance', label: '100% Attendance', perWeek: 5, perDay: 1 }, - { id: 'poll', label: 'Complete every Daily Poll', perWeek: 5, perDay: 1 }, - { id: 'discussion', label: 'Participate in Daily Discussions', perWeek: 5, perDay: 1 }, - { id: 'challenge', label: "Complete this Week's Challenge", perWeek: 1, perDay: 0 } - ], - average: [ - { id: 'attendance', label: '100% Attendance', perWeek: 5, perDay: 1 }, - { id: 'poll', label: 'Daily Poll Participation', perWeek: 4, perDay: 1 }, - { id: 'discussion', label: 'Join at least 3 Discussions', perWeek: 3, perDay: 0 }, - { id: 'challenge', label: 'Complete Weekly Challenge', perWeek: 1, perDay: 0 } - ], - bottom: [ - { id: 'attendance', label: 'Attend every session', perWeek: 5, perDay: 1 }, - { id: 'poll', label: 'Complete every Daily Poll', perWeek: 5, perDay: 1 }, - { id: 'discussion', label: 'Join one Discussion every day', perWeek: 5, perDay: 1 }, - { id: 'challenge', label: 'Complete the Weekly Challenge', perWeek: 1, perDay: 0 } - ] - }[bucket]; - - // How many SP needed to climb + projected rank after the plan. - // Conservative but motivating estimates. - const spByTarget = { attendance: 12, poll: 8, discussion: 6, challenge: 10 }; - const requiredSp = targets.reduce((s, t) => s + (spByTarget[t.id] || 5), 0); - - const projectedRank = bucket === 'close' ? 'Top 10' - : bucket === 'average' ? 'Top 20' - : 'Top 30'; - - const title = bucket === 'close' ? '🎯 Weekly Goal' - : bucket === 'average' ? 'πŸš€ Keep Growing' - : 'πŸ’™ Fresh Start'; - - // The headline copy β€” uses the actual rank distance for 'close'. - let headline; - let subhead; - if (bucket === 'close') { - const ranksAway = rank - 10; - headline = `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from becoming a Weekly Champion.`; - subhead = "Stay consistent this week and you'll have a great chance of reaching the Top 10."; - } else if (bucket === 'average') { - headline = 'You made steady progress last week.'; - subhead = "Maintain your consistency and aim for the Top 20."; - } else { - headline = 'Every week is a new beginning.'; - subhead = 'Small daily improvements will help you move up quickly.'; - } - - return { - bucket, - title, - headline, - subhead, - targets, - requiredSp, - projectedRank, - priorRank: rank, - priorWeeklySp: me.weeklySp - }; -} - -// Public: build the goal payload for any student (used by the recap -// endpoint so the WeeklyGoalCard can render even if the student -// wasn't in the bottom 50). -export async function goalFor(email) { - const recap = await latestRecap(); - if (!recap) return null; - const allRanked = recap.allRanked || []; - const me = allRanked.find(r => r.email === email); - if (!me) return null; - const goal = deriveWeeklyGoal(me, recap); - return { - weekStart: recap.weekStart, - weekEnd: recap.weekEnd, - ...goal - }; -} - -// ============================================================ -// Live Weekly Progress (current week only) -// Pulls this-week transaction counts and attendance + poll -// participation for the student so the WeeklyGoalCard can show -// real-time progress. -// ============================================================ - -export async function liveProgressFor(email) { - if (!email) return null; - const week = weekContaining(); - const startMs = week.startMs; - const endMs = week.endMs; - - // Per-category counts this week (from SPTransaction) - const txns = await SPTransaction.find({ - email, - dateTime: { $gte: new Date(startMs), $lte: new Date(endMs) } - }).select('category sessionLabel dateTime').lean(); - - let attendance = 0, poll = 0, challenge = 0; - // Track daily attendance for streak calculation - const attendanceDays = new Set(); - const IST_OFFSET_MIN = 330; - for (const t of txns) { - if (t.category === 'attendance') { - attendance += 1; - const s = new Date(t.dateTime.getTime() + IST_OFFSET_MIN * 60_000); - attendanceDays.add(`${s.getUTCFullYear()}-${s.getUTCMonth() + 1}-${s.getUTCDate()}`); - } - else if (t.category === 'poll') poll += 1; - else if (t.category === 'manual' && /challenge/i.test(t.sessionLabel || '')) challenge += 1; - } - - // Discussion participation β€” for now we approximate from any manual - // SP that isn't an attendance/poll/challenge (closest stand-in until - // a dedicated discussion endpoint exists). When the chat SP layer - // is added, this can switch to read from chatrecords. - const discussion = txns.filter(t => - t.category === 'manual' && !/challenge/i.test(t.sessionLabel || '') - ).length; - - // Weekly SP so far - const spAgg = await SPTransaction.aggregate([ - { $match: { - email, - dateTime: { $gte: new Date(startMs), $lte: new Date(endMs) } - } }, - { $group: { _id: null, total: { $sum: '$appliedDelta' } } } - ]); - const weeklySp = spAgg[0]?.total || 0; - - // Streak (consecutive days with attendance) - let streak = 0; - let dayCursor = Date.now(); - for (;;) { - const s = new Date(dayCursor + IST_OFFSET_MIN * 60_000); - const k = `${s.getUTCFullYear()}-${s.getUTCMonth() + 1}-${s.getUTCDate()}`; - if (attendanceDays.has(k)) { - streak += 1; - dayCursor -= 86400000; - } else break; - if (streak > 30) break; - } - - return { - weekStart: week.startMs, - attendance, - poll, - discussion, - challenge, - streak, - weeklySp, - attendanceDays: attendanceDays.size - }; } \ No newline at end of file From 5a77f40a0ea4f73a12f44bd95145cce9905f735c Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Tue, 21 Jul 2026 22:57:01 +0530 Subject: [PATCH 15/31] feat: replace badge system with new 16-rank gamified progression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (server/services/levels.js): - Replaces the old Bronzeβ†’Silverβ†’Goldβ†’Platinumβ†’Diamondβ†’Legend hierarchy with the new spec: 16 named ranks across 6 tiers (Bronze/Silver/Gold/Diamond/Heroic/Master), evenly distributed from 100 SP (starting) to 1500 SP (max) - Adds rankFor(), nextRank(), decorated accessors - Keeps legacy leagueBand() + levelFor() + legendBadge() + leaderboard helpers so other callers don't break Frontend (new client/src/components/rank-system/): - ranks.js β€” client-side mirror of the rank table (the client bundle can't cross the server boundary), with per-tier gradient/glow themes and per-rank descriptions - BadgeArt.jsx β€” six original SVG emblems (one per tier): bronze shield, silver wings, gold crystal, diamond crystal, heroic crest with energy wings, master 8-point star with rotating rings. No Free Fire references β€” fully original geometry - JourneyProgressTrack.jsx β€” the main experience: a horizontal track spanning 100β†’1500 SP, 16 milestone markers (one per rank), a continuously-running mini-character above the track that dashes + leaves speed trails when SP changes, hover tooltips revealing rank name + description, a centered "RANK PROMOTED!" panel that appears on rank-up with glow + pulse + rotation, and a current/next-rank footer with animated progress bar - AchievementCelebration.jsx β€” bottom-right toast stack that appears on rank-up; auto-dismisses after 4.5s with a draining timer bar; user can dismiss manually with the Γ— button - RankJourney.jsx β€” orchestrator that ties it all together; listens for SP changes via props, fires the runner dash + queues the celebration toast on rank-up - rank-system.css β€” full design system: runner bobbing animation, dashing animation, trail effects, milestone pulse, promotion pulse, toast slide-in Wired into main.jsx: replaces the old "Level / Trophy League / Legend Badge" LevelStatus tile with Build: 771 modules, 76.6 KB CSS / 937.9 KB JS. --- .../rank-system/AchievementCelebration.jsx | 56 +++ .../src/components/rank-system/BadgeArt.jsx | 189 ++++++++ .../rank-system/JourneyProgressTrack.jsx | 311 +++++++++++++ .../components/rank-system/RankJourney.jsx | 54 +++ .../components/rank-system/rank-system.css | 439 ++++++++++++++++++ client/src/components/rank-system/ranks.js | 123 +++++ client/src/main.jsx | 3 +- server/services/levels.js | 90 ++-- 8 files changed, 1232 insertions(+), 33 deletions(-) create mode 100644 client/src/components/rank-system/AchievementCelebration.jsx create mode 100644 client/src/components/rank-system/BadgeArt.jsx create mode 100644 client/src/components/rank-system/JourneyProgressTrack.jsx create mode 100644 client/src/components/rank-system/RankJourney.jsx create mode 100644 client/src/components/rank-system/rank-system.css create mode 100644 client/src/components/rank-system/ranks.js diff --git a/client/src/components/rank-system/AchievementCelebration.jsx b/client/src/components/rank-system/AchievementCelebration.jsx new file mode 100644 index 0000000..3ba8fee --- /dev/null +++ b/client/src/components/rank-system/AchievementCelebration.jsx @@ -0,0 +1,56 @@ +import React, { useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { BadgeArt } from './BadgeArt'; +import { RANK_DESCRIPTIONS } from './ranks'; + +// ============================================================ +// AchievementCelebration +// Small bottom-right toast that appears when a new rank is unlocked. +// Auto-dismisses after a few seconds. Multiple toasts stack. +// ============================================================ + +export function AchievementCelebration({ queue, onDismiss }) { + return ( +
+ + {queue.map((evt) => ( + +
+ +
+
+
πŸŽ‰ RANK UP
+
+ Promoted to {evt.to.name} +
+
{RANK_DESCRIPTIONS[evt.to.name]}
+
+ +
+ onDismiss(evt.id)} + /> +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/rank-system/BadgeArt.jsx b/client/src/components/rank-system/BadgeArt.jsx new file mode 100644 index 0000000..2a83add --- /dev/null +++ b/client/src/components/rank-system/BadgeArt.jsx @@ -0,0 +1,189 @@ +import React from 'react'; + +// ============================================================ +// BadgeArt β€” original SVG emblems for each rank tier. +// Six unique designs (bronze, silver, gold, diamond, heroic, master) +// rendered as inline SVG so they scale cleanly and animate via CSS +// transforms. The `tier` prop picks the design; the `size` prop +// controls the rendered width. No external assets, no Free Fire +// references β€” purely original geometric artwork. +// ============================================================ + +function BronzeShield({ size, glow, accent }) { + return ( + + + + + + + + + + + + + + ); +} + +function SilverWings({ size, glow, accent }) { + return ( + + + + + + + + + + + + + + ); +} + +function GoldCrystal({ size, glow, accent }) { + return ( + + + + + + + + + + + + + + + ); +} + +function DiamondCrystal({ size, glow, accent }) { + return ( + + + + + + + + + + + + + + + + ); +} + +function HeroicCrest({ size, glow, accent }) { + return ( + + + + + + + + + + + + + + + {/* Energy wings */} + + + + + + + + ); +} + +function MasterEmblem({ size, glow, accent }) { + // Massive futuristic glow β€” purple β†’ gold β†’ white gradient with + // animated concentric rings. The "master" tier gets the most + // elaborate treatment to feel earned. + return ( + + + + + + + + + + + + + + + + + {/* Outer aura */} + + {/* Outer rotating ring */} + + + + + + + + + {/* Eight-point star */} + + {/* Crown spikes */} + + + + + + {/* Central diamond */} + + + + ); +} + +export function BadgeArt({ tier, size = 56, glow, accent }) { + const props = { size, glow: glow || '#FFFFFF', accent: accent || '#FFFFFF' }; + switch (tier) { + case 'bronze': return ; + case 'silver': return ; + case 'gold': return ; + case 'diamond': return ; + case 'heroic': return ; + case 'master': return ; + default: return ; + } +} + +// Compact mini badge used inside the journey track markers. +export function MiniBadge({ tier, size = 24, fill, stroke }) { + return ( + + + + + ); +} \ No newline at end of file diff --git a/client/src/components/rank-system/JourneyProgressTrack.jsx b/client/src/components/rank-system/JourneyProgressTrack.jsx new file mode 100644 index 0000000..1005954 --- /dev/null +++ b/client/src/components/rank-system/JourneyProgressTrack.jsx @@ -0,0 +1,311 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { BadgeArt, MiniBadge } from './BadgeArt'; +import { RANKS, TIER_THEME, RANK_DESCRIPTIONS, rankFor, nextRank, MAX_SP, STARTING_SP } from './ranks'; + +// ============================================================ +// JourneyProgressTrack +// A horizontal track stretching from STARTING_SP to MAX_SP. All 16 +// rank checkpoints are placed along it. A small runner character +// runs continuously above the track; when SP changes, the runner +// dashes forward and leaves speed trails before the count-up catches +// up. Hovering a checkpoint reveals a tooltip with the rank name + +// description. +// ============================================================ + +// SP range mapped to the track [0, 1] for the runner x position. +function spToPct(sp) { + const v = Math.max(STARTING_SP, Math.min(MAX_SP, sp)); + return ((v - STARTING_SP) / (MAX_SP - STARTING_SP)) * 100; +} + +function pctToTrackX(pct, trackWidth) { + // Convert track % to an absolute x. The marker centers are pinned + // by CSS, so we just return a percentage of the track width. + return (pct / 100) * trackWidth; +} + +// Running mini-character β€” original SVG silhouette with bobbing +// arms. Drawn as inline SVG so it can be transformed via CSS. +function Runner({ dashing }) { + return ( + + ); +} + +// Hook: live count-up of a numeric value, easeOutCubic over `duration`. +function useCountUp(target, duration = 700) { + const [v, setV] = useState(target); + const prev = useRef(target); + useEffect(() => { + const from = prev.current; + const to = Number(target) || 0; + if (from === to) { setV(to); return; } + const t0 = performance.now(); + let raf; + const tick = (now) => { + const p = Math.min((now - t0) / duration, 1); + const eased = 1 - Math.pow(1 - p, 3); + setV(Math.round(from + (to - from) * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + else prev.current = to; + }; + raf = requestAnimationFrame(tick); + return () => raf && cancelAnimationFrame(raf); + }, [target]); + return v; +} + +function MilestoneMarker({ rank, sp, currentSp, isCompleted, isCurrent, theme, onHover, onLeave, isHovered }) { + const pct = spToPct(sp); + const completed = currentSp >= sp; + return ( +
+
+ {completed + ? βœ“ + : {sp >= 1000 ? `${(sp/1000).toFixed(1)}k` : sp} + } +
+
+ {rank.name} +
+ + + {isHovered && ( + +
+ {rank.name} +
+
+ {sp} SP + Β· + Rank {rank.idx} / 16 +
+
{RANK_DESCRIPTIONS[rank.name]}
+
+ )} +
+
+ ); +} + +export function JourneyProgressTrack({ sp, onPromoted }) { + const [hoverIdx, setHoverIdx] = useState(null); + const trackRef = useRef(null); + const previousSp = useRef(sp); + const [isDashing, setIsDashing] = useState(false); + const [promotion, setPromotion] = useState(null); + + const rank = useMemo(() => rankFor(sp), [sp]); + const next = useMemo(() => nextRank(sp), [sp]); + const displaySp = useCountUp(sp); + + // Detect SP changes β†’ dashing runner + rank-up event. + useEffect(() => { + if (previousSp.current === sp) return; + if (sp > previousSp.current) { + const prevRank = rankFor(previousSp.current); + const newRank = rankFor(sp); + if (newRank.idx > prevRank.idx) { + setIsDashing(true); + setTimeout(() => setIsDashing(false), 1200); + setPromotion({ from: prevRank, to: newRank }); + setTimeout(() => { + setPromotion(null); + onPromoted && onPromoted({ from: prevRank, to: newRank }); + }, 3000); + } else { + setIsDashing(true); + setTimeout(() => setIsDashing(false), 900); + } + } + previousSp.current = sp; + }, [sp, onPromoted]); + + const progressPct = spToPct(sp); + const currentIdx = rank.idx; + + return ( +
+
+
+
SP JOURNEY
+
+ {displaySp} + SP +
+
+
+
+
+ {rank.name} +
+
+ Tier {rank.theme.label} Β· Rank {rank.idx}/16 +
+
+
+
+ +
+ {/* Track rail (gradient from start to end) */} +
+
+
+ + {/* Milestone markers */} + {RANKS.map((r, i) => ( + = r.min} + isCurrent={currentIdx === r.idx} + theme={r.theme} + isHovered={hoverIdx === r.idx} + onHover={() => setHoverIdx(r.idx)} + onLeave={() => setHoverIdx(null)} + /> + ))} + + {/* Runner */} +
+
+ {isDashing &&
} + {isDashing &&
} + +
+
+ +
+
+
SP
+ {STARTING_SP} +
+ {next ? ( +
+
NEXT RANK
+
+ + {next.rank.name} + + Β· + + {next.spNeeded} SP to go + +
+
+ +
+
+ ) : ( +
+
MAXED OUT
+ Master rank achieved +
+ )} +
+
SP
+ {MAX_SP} +
+
+ + + {promotion && ( + +
✨ RANK PROMOTED!
+
{promotion.to.name}
+
+ From {promotion.from.name} β†’ {promotion.to.name} +
+
+ +
+
+ {RANK_DESCRIPTIONS[promotion.to.name]} +
+
+ )} +
+
+ ); +} + +// Big "current rank" display with full-size badge art + meta info. +export function CurrentRankBadge({ sp, profile }) { + const rank = useMemo(() => rankFor(sp), [sp]); + const next = useMemo(() => nextRank(sp), [sp]); + const tier = rank.theme; + return ( +
+
+ +
+
+
{tier.label.toUpperCase()} TIER
+
{rank.name}
+
{RANK_DESCRIPTIONS[rank.name]}
+ {next && ( +
+ Next: {next.rank.name} + Β· + {next.spNeeded} SP to go +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/rank-system/RankJourney.jsx b/client/src/components/rank-system/RankJourney.jsx new file mode 100644 index 0000000..e334a99 --- /dev/null +++ b/client/src/components/rank-system/RankJourney.jsx @@ -0,0 +1,54 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { CurrentRankBadge, JourneyProgressTrack } from './JourneyProgressTrack'; +import { AchievementCelebration } from './AchievementCelebration'; +import { rankFor } from './ranks'; +import './rank-system.css'; + +// ============================================================ +// RankJourney +// Top-level page-level container that combines: +// - CurrentRankBadge (the hero β€” current rank + description) +// - JourneyProgressTrack (the long track with 16 checkpoints) +// - AchievementCelebration (bottom-right toast when rank up) +// Listens to SP changes (via props) and queues promotion toasts. +// ============================================================ + +export function RankJourney({ sp, profile }) { + const [toasts, setToasts] = useState([]); + const toastSeq = useRef(0); + const previousSp = useRef(sp); + const previousRankIdx = useRef(rankFor(sp).idx); + + // Detect rank-up events and queue a celebration toast. + useEffect(() => { + const newRank = rankFor(sp); + if (newRank.idx > previousRankIdx.current) { + toastSeq.current += 1; + const id = toastSeq.current; + setToasts(prev => [...prev, { id, to: newRank }]); + previousRankIdx.current = newRank.idx; + } else if (sp !== previousSp.current) { + previousRankIdx.current = newRank.idx; + } + previousSp.current = sp; + }, [sp, toastSeq]); + + const dismiss = (id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }; + + return ( +
+ + { + toastSeq.current += 1; + const id = toastSeq.current; + setToasts(prev => [...prev, { id, to: evt.to }]); + }} + /> + +
+ ); +} \ No newline at end of file diff --git a/client/src/components/rank-system/rank-system.css b/client/src/components/rank-system/rank-system.css new file mode 100644 index 0000000..b1c3ccd --- /dev/null +++ b/client/src/components/rank-system/rank-system.css @@ -0,0 +1,439 @@ +/* ============================================================ + Rank System β€” Journey, Badges, Promotion + Premium enterprise look with futuristic accents. Light & dark. + ============================================================ */ + +:root { + --rk-track-h: 64px; + --rk-marker-size: 28px; + --rk-runner-size: 28px; +} + +/* ===== Current Rank Badge (the hero) ===== */ +.rk-current { + display: flex; align-items: center; gap: 18px; + padding: 16px 18px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(255,255,255,0.06) 0%, transparent 50%), + radial-gradient(at 100% 100%, var(--rk-glow, #6366f1) 0%, transparent 60%), + var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + position: relative; + overflow: hidden; +} +.rk-current::before { + content: ''; + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 35%); + pointer-events: none; +} +.rk-current__left { flex-shrink: 0; filter: drop-shadow(0 4px 12px var(--rk-glow)); } +.rk-current__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.rk-current__eyebrow { + font-size: 9px; font-weight: 800; letter-spacing: 0.16em; + color: var(--text-dim); +} +.rk-current__name { + font-size: 22px; font-weight: 900; + letter-spacing: -0.01em; + line-height: 1.1; +} +.rk-current__desc { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; + max-width: 540px; +} +.rk-current__next { + display: flex; align-items: center; gap: 6px; + font-size: 10.5px; font-weight: 700; + color: var(--text-muted); + margin-top: 4px; +} +.rk-current__next b { font-weight: 900; } +.rk-current__next-sep { color: var(--text-dim); } + +/* ===== Journey Progress Track ===== */ +.rk-track-wrap { + position: relative; + padding: 14px 18px 16px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(99,102,241,0.06) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(139,92,246,0.05) 0%, transparent 40%), + var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + overflow: visible; +} + +.rk-track-head { + display: flex; align-items: flex-end; justify-content: space-between; + margin-bottom: 10px; +} +.rk-track-eyebrow { + font-size: 9px; font-weight: 800; letter-spacing: 0.16em; + color: var(--text-dim); + margin-bottom: 2px; +} +.rk-track-sp { + display: flex; align-items: baseline; gap: 4px; + font-variant-numeric: tabular-nums; +} +.rk-track-sp-val { + font-size: 28px; font-weight: 900; + background: var(--grad-brand); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + line-height: 1; +} +.rk-track-sp-label { + font-size: 10px; font-weight: 800; letter-spacing: 0.1em; + color: var(--text-dim); +} +.rk-track-current { + text-align: right; +} +.rk-track-current-rank { + font-size: 14px; font-weight: 900; + letter-spacing: -0.005em; + line-height: 1.1; +} +.rk-track-current-meta { + font-size: 9.5px; font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.04em; +} + +/* ===== Track rail ===== */ +.rk-track { + position: relative; + height: 38px; + margin: 18px 0 28px; +} +.rk-track-rail { + position: absolute; + left: 0; right: 0; + top: 50%; + transform: translateY(-50%); + height: 8px; + background: var(--surface-strong); + border-radius: 999px; + border: 1px solid var(--border); + overflow: hidden; +} +.rk-track-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1 0%, var(--rk-glow, #06b6d4) 100%); + border-radius: 999px; + box-shadow: 0 0 8px var(--rk-glow, rgba(6,182,212,0.5)); + transition: width 0.8s var(--wl-ease, cubic-bezier(0.22, 1, 0.36, 1)); +} + +/* Milestone markers */ +.rk-milestone { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + display: flex; flex-direction: column; align-items: center; + gap: 4px; + z-index: 2; +} +.rk-milestone__node { + width: var(--rk-marker-size); height: var(--rk-marker-size); + display: grid; place-items: center; + border-radius: 50%; + border: 1.5px solid; + font-size: 9.5px; font-weight: 900; + font-variant-numeric: tabular-nums; + color: #fff; + background-clip: padding-box; + transition: transform 0.2s, box-shadow 0.2s; +} +.rk-milestone.is-completed .rk-milestone__node { + color: #fff; + box-shadow: 0 0 0 2px rgba(255,255,255,0.15), 0 0 14px currentColor; +} +.rk-milestone.is-current .rk-milestone__node { + transform: scale(1.15); + animation: rk-current-pulse 1.8s ease-in-out infinite; +} +@keyframes rk-current-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(255,255,255,0.15), 0 0 14px currentColor; } + 50% { box-shadow: 0 0 0 4px rgba(255,255,255,0.3), 0 0 22px currentColor; } +} +.rk-milestone__check { font-size: 13px; } +.rk-milestone__sp { font-size: 9.5px; } +.rk-milestone__label { + position: absolute; + top: calc(100% + 4px); + font-size: 8.5px; font-weight: 800; letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +/* Tooltip */ +.rk-milestone__tip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: 200px; + padding: 10px 12px; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border-strong); + box-shadow: 0 8px 20px rgba(0,0,0,0.18), 0 0 0 1px var(--wgc-glow) inset; + z-index: 10; + pointer-events: none; +} +.rk-milestone__tip-rank { + font-size: 12px; font-weight: 900; + margin-bottom: 4px; +} +.rk-milestone__tip-meta { + display: flex; gap: 4px; align-items: baseline; + font-size: 10px; font-weight: 700; + color: var(--text-muted); + margin-bottom: 4px; +} +.rk-milestone__tip-sep { color: var(--text-dim); } +.rk-milestone__tip-desc { + font-size: 10.5px; + color: var(--text); + line-height: 1.4; +} + +/* Runner */ +.rk-runner-wrap { + position: absolute; + top: 50%; + transform: translate(-50%, calc(-50% - 18px)); + z-index: 3; + pointer-events: none; + transition: left 0.8s cubic-bezier(0.22, 1, 0.36, 1); +} +.rk-runner { + color: var(--rk-runner-color, #06b6d4); + filter: drop-shadow(0 0 6px currentColor); + animation: rk-bob 0.6s ease-in-out infinite alternate; +} +@keyframes rk-bob { + 0% { transform: translateY(0) rotate(-3deg); } + 100% { transform: translateY(-2px) rotate(3deg); } +} +.rk-runner.is-dashing { + animation: rk-dash 0.18s ease-in-out infinite alternate; +} +@keyframes rk-dash { + 0% { transform: translateY(-1px) rotate(-6deg) scale(1.0); } + 100% { transform: translateY(-3px) rotate(6deg) scale(1.15); } +} +.rk-runner-aura { + position: absolute; + left: 50%; top: 50%; + transform: translate(-50%, -50%); + width: 32px; height: 32px; + border-radius: 50%; + background: var(--rk-runner-color, #06b6d4); + opacity: 0.18; + z-index: -1; + filter: blur(8px); + transition: opacity 0.3s, transform 0.3s; +} +.rk-runner-aura.is-active { + opacity: 0.4; + transform: translate(-50%, -50%) scale(1.5); +} +.rk-runner-trail { + position: absolute; + right: 100%; + top: 50%; + width: 50px; height: 4px; + background: linear-gradient(90deg, transparent 0%, var(--rk-runner-color, #06b6d4) 100%); + border-radius: 999px; + filter: blur(2px); + transform: translateY(-50%); + animation: rk-trail 0.6s ease-out forwards; +} +.rk-runner-trail--2 { + width: 30px; + opacity: 0.6; + animation-delay: 0.08s; +} +@keyframes rk-trail { + 0% { width: 0; opacity: 0.8; } + 100% { width: 50px; opacity: 0; } +} + +/* Footer (start / next / end labels) */ +.rk-track-foot { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: end; + gap: 14px; + padding-top: 8px; +} +.rk-track-foot__sp { + display: flex; flex-direction: column; align-items: center; gap: 2px; +} +.rk-track-sp-min, .rk-track-sp-max { + font-size: 12px; font-weight: 800; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.rk-track-foot__next { + display: flex; flex-direction: column; gap: 4px; + min-width: 0; +} +.rk-track-foot__next-row { + display: flex; align-items: baseline; gap: 6px; + font-size: 11px; + color: var(--text); + font-weight: 700; +} +.rk-track-foot__next-name { font-weight: 900; font-size: 12px; } +.rk-track-foot__next-sep { color: var(--text-dim); } +.rk-track-foot__next-bar { + height: 5px; border-radius: 999px; + background: var(--surface-strong); + overflow: hidden; + border: 1px solid var(--border); +} +.rk-track-foot__next-fill { + height: 100%; + border-radius: inherit; +} +.rk-track-foot__max { + display: flex; flex-direction: column; gap: 2px; + align-items: center; +} +.rk-track-foot__max-name { + font-size: 11px; font-weight: 800; + color: #6B21A8; + text-align: center; +} + +/* Promotion overlay (centered) */ +.rk-promotion { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + z-index: 30; + padding: 18px 24px; + border-radius: 16px; + background: + radial-gradient(at 50% 0%, var(--rk-glow, #FFD700) 0%, transparent 60%), + var(--surface); + border: 1.5px solid var(--rk-glow, #FFD700); + box-shadow: 0 0 0 3px rgba(255,255,255,0.06), 0 0 32px var(--rk-glow, #FFD700); + text-align: center; + z-index: 10; + min-width: 240px; + animation: rk-promotion-pulse 1.6s ease-in-out infinite; +} +@keyframes rk-promotion-pulse { + 0%, 100% { box-shadow: 0 0 0 3px rgba(255,255,255,0.06), 0 0 32px var(--rk-glow); } + 50% { box-shadow: 0 0 0 5px rgba(255,255,255,0.18), 0 0 48px var(--rk-glow); } +} +.rk-promotion__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.18em; + color: var(--rk-glow); + margin-bottom: 4px; +} +.rk-promotion__title { + font-size: 22px; font-weight: 900; + margin-bottom: 4px; +} +.rk-promotion__sub { + font-size: 10.5px; font-weight: 700; + color: var(--text-muted); + margin-bottom: 8px; +} +.rk-promotion__art { + display: flex; justify-content: center; + margin: 6px 0 8px; + animation: rk-promotion-art 2.4s ease-in-out infinite; +} +@keyframes rk-promotion-art { + 0%, 100% { transform: scale(1) rotate(-3deg); filter: drop-shadow(0 4px 12px var(--rk-glow)); } + 50% { transform: scale(1.1) rotate(3deg); filter: drop-shadow(0 6px 18px var(--rk-glow)); } +} +.rk-promotion__msg { + font-size: 10.5px; font-weight: 600; + color: var(--text); + line-height: 1.4; +} + +/* ===== Achievement Celebration toast (bottom-right) ===== */ +.rk-celebration-stack { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1400; + display: flex; flex-direction: column; gap: 8px; + pointer-events: none; +} +.rk-celebration { + position: relative; + display: flex; align-items: center; gap: 12px; + width: 300px; + padding: 12px 32px 12px 14px; + border-radius: 14px; + background: + radial-gradient(at 0% 0%, var(--rk-glow, #06b6d4) 0%, transparent 50%), + var(--surface); + border: 1px solid var(--border-strong); + box-shadow: 0 12px 32px rgba(0,0,0,0.25), 0 0 0 1px var(--rk-glow) inset; + pointer-events: auto; + overflow: hidden; +} +.rk-celebration__art { flex-shrink: 0; filter: drop-shadow(0 0 6px var(--rk-glow)); } +.rk-celebration__body { min-width: 0; flex: 1; } +.rk-celebration__eyebrow { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.16em; + color: var(--rk-glow, #06b6d4); + margin-bottom: 2px; +} +.rk-celebration__title { + font-size: 13px; font-weight: 800; + color: var(--text); + line-height: 1.2; +} +.rk-celebration__sub { + font-size: 10.5px; + color: var(--text-muted); + line-height: 1.3; + margin-top: 2px; +} +.rk-celebration__close { + position: absolute; + top: 4px; right: 4px; + width: 22px; height: 22px; + border: 0; + background: transparent; + color: var(--text-dim); + font-size: 16px; line-height: 1; + cursor: pointer; + border-radius: 50%; +} +.rk-celebration__close:hover { background: var(--surface-2); color: var(--text); } +.rk-celebration__timer { + position: absolute; left: 0; bottom: 0; + height: 2px; + width: 100%; + background: transparent; +} +.rk-celebration__timer-fill { + height: 100%; + background: var(--rk-glow, #06b6d4); + opacity: 0.6; +} + +@media (max-width: 720px) { + .rk-celebration { width: 260px; } + .rk-current { flex-direction: column; align-items: flex-start; } +} \ No newline at end of file diff --git a/client/src/components/rank-system/ranks.js b/client/src/components/rank-system/ranks.js new file mode 100644 index 0000000..6712846 --- /dev/null +++ b/client/src/components/rank-system/ranks.js @@ -0,0 +1,123 @@ +// ============================================================ +// Rank System β€” data layer (client-side mirror of server/levels.js) +// The client cannot import the server module directly (Vite's resolver +// separates the two bundles). We keep a small parallel definition +// here β€” the server is still the source of truth for the rank name; +// this file only adds the visual metadata (theme colors, descriptions). +// ============================================================ + +// Rank table β€” must mirror server/services/levels.js +const RANK_TABLE = [ + { min: 1500, name: 'Master', tier: 'master', idx: 16 }, + { min: 1400, name: 'Heroic I', tier: 'heroic', idx: 15 }, + { min: 1300, name: 'Heroic II', tier: 'heroic', idx: 14 }, + { min: 1200, name: 'Heroic III', tier: 'heroic', idx: 13 }, + { min: 1100, name: 'Diamond I', tier: 'diamond', idx: 12 }, + { min: 1000, name: 'Diamond II', tier: 'diamond', idx: 11 }, + { min: 900, name: 'Diamond III', tier: 'diamond', idx: 10 }, + { min: 800, name: 'Gold I', tier: 'gold', idx: 9 }, + { min: 700, name: 'Gold II', tier: 'gold', idx: 8 }, + { min: 600, name: 'Gold III', tier: 'gold', idx: 7 }, + { min: 500, name: 'Silver I', tier: 'silver', idx: 6 }, + { min: 400, name: 'Silver II', tier: 'silver', idx: 5 }, + { min: 300, name: 'Silver III', tier: 'silver', idx: 4 }, + { min: 200, name: 'Bronze I', tier: 'bronze', idx: 3 }, + { min: 100, name: 'Bronze II', tier: 'bronze', idx: 2 }, + { min: 0, name: 'Bronze III', tier: 'bronze', idx: 1 } +]; + +export const STARTING_SP = 100; +export const MAX_SP = 1500; +export const RANKS = RANK_TABLE.slice().sort((a, b) => a.min - b.min); + +export function rankFor(sp) { + const v = Math.max(0, Math.min(MAX_SP, Number(sp) || 0)); + for (let i = RANKS.length - 1; i >= 0; i--) { + if (v >= RANKS[i].min) return RANKS[i]; + } + return RANKS[0]; +} + +export function nextRank(sp) { + const v = Math.max(0, Math.min(MAX_SP, Number(sp) || 0)); + for (let i = 0; i < RANKS.length; i++) { + if (v < RANKS[i].min) return { rank: RANKS[i], spNeeded: RANKS[i].min - v }; + } + return null; +} + +// Per-tier visual treatment +export const TIER_THEME = { + bronze: { + label: 'Bronze', + gradient: 'linear-gradient(135deg, #CD7F32 0%, #8B4513 60%, #5C2C0C 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(205,127,50,0.18) 0%, rgba(139,69,19,0.10) 100%)', + glow: '#CD7F32', + accent: '#FFD89A', + text: '#7A4A1B' + }, + silver: { + label: 'Silver', + gradient: 'linear-gradient(135deg, #E8E8E8 0%, #B8B8B8 50%, #707070 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(232,232,232,0.22) 0%, rgba(112,112,112,0.10) 100%)', + glow: '#C0C0C0', + accent: '#FFFFFF', + text: '#4A4A4A' + }, + gold: { + label: 'Gold', + gradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #B8860B 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(255,215,0,0.20) 0%, rgba(184,134,11,0.10) 100%)', + glow: '#FFD700', + accent: '#FFF1A8', + text: '#8A6500' + }, + diamond: { + label: 'Diamond', + gradient: 'linear-gradient(135deg, #4FACFE 0%, #00C2FE 50%, #0050B3 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(79,172,254,0.20) 0%, rgba(0,80,179,0.10) 100%)', + glow: '#4FACFE', + accent: '#BDE2FF', + text: '#003C7A' + }, + heroic: { + label: 'Heroic', + gradient: 'linear-gradient(135deg, #DC143C 0%, #8B0000 40%, #1E40AF 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(220,20,60,0.20) 0%, rgba(30,64,175,0.18) 100%)', + glow: '#DC143C', + accent: '#FF6B8A', + text: '#6B0000' + }, + master: { + label: 'Master', + gradient: 'linear-gradient(135deg, #6B21A8 0%, #FFD700 50%, #FFFFFF 100%)', + gradientSoft: 'linear-gradient(135deg, rgba(107,33,168,0.22) 0%, rgba(255,215,0,0.20) 100%)', + glow: '#FFD700', + accent: '#F0E0FF', + text: '#3B0764' + } +}; + +export const RANK_DESCRIPTIONS = { + 'Bronze III': 'Beginning the learning journey. Show up and earn.', + 'Bronze II': 'Building consistency. Every session counts.', + 'Bronze I': 'Solid attendance. You are ready for the next tier.', + 'Silver III': 'Crossed into Silver. You are part of the top 70%.', + 'Silver II': 'Strong consistency. Keep your daily streak alive.', + 'Silver I': 'Polished learner. You are one rank away from Gold.', + 'Gold III': 'Entered Gold. You stand out from the cohort.', + 'Gold II': 'Reliable performer. You are now in the top 40%.', + 'Gold I': 'Consistent high performer. Diamond is within reach.', + 'Diamond III': 'First Diamond tier. You are in the top 25%.', + 'Diamond II': 'Strong academic record. Heroic awaits.', + 'Diamond I': 'Elite tier. You are now in the top 15%.', + 'Heroic III': 'Heroic unlocked. The top 10% of the cohort.', + 'Heroic II': 'Outstanding performance. One step from Master.', + 'Heroic I': 'Final stretch. Master is one rank away.', + 'Master': 'Maximum achievement. The pinnacle of the program.' +}; + +export function decorateRank(rank) { + if (!rank) return null; + return { ...rank, theme: TIER_THEME[rank.tier], description: RANK_DESCRIPTIONS[rank.name] }; +} \ No newline at end of file diff --git a/client/src/main.jsx b/client/src/main.jsx index ebd8e81..a834c5b 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -10,6 +10,7 @@ import { isFinalJourneyUnlocked, buildReplayHistory } from './components/replay/ import './components/replay/replay.css'; import { WeeklyLeaderboardDesktop } from './components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx'; import './components/weekly-leaderboard/WeeklyLeaderboardDesktop.css'; +import { RankJourney } from './components/rank-system/RankJourney'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -279,7 +280,7 @@ function StudentView({ profile, onBack }) {
SP{student.totalSp}Rank {student.rank} of {student.cohortSize}
- + diff --git a/server/services/levels.js b/server/services/levels.js index e8603a8..d8c05c9 100644 --- a/server/services/levels.js +++ b/server/services/levels.js @@ -1,50 +1,76 @@ /** - * Spurti Levels, Trophy Leagues, Legend status, and biweekly onboarding groups. + * Spurti Ranks β€” gamified progression system inspired by ranked emblems. + * Pure functions, no DB writes. The student's current rank is derived + * from their total SP. * - * These are DERIVED VIEWS over the existing SP system β€” pure functions, no DB, - * no side effects. SP transactions and balances are never changed here. - * Spec: research/05_experiments/spurti_levels_leagues/samagama_spurti_levels_leagues_spec.md + * Spec (May 2026 redesign): every intern starts at 100 SP and tops out at + * 1500 SP ("Master"). 16 named ranks across 6 tiers, distributed evenly + * along the 100–1500 span. */ -// Current SP -> Trophy League. Exact bands from the spec (Β§4). -const LEAGUE_BANDS = [ - [1500, Infinity, 'Legend'], - [1400, 1499, 'Diamond I'], - [1300, 1399, 'Diamond II'], - [1200, 1299, 'Diamond III'], - [1100, 1199, 'Platinum I'], - [1000, 1099, 'Platinum II'], - [900, 999, 'Platinum III'], - [800, 899, 'Gold I'], - [700, 799, 'Gold II'], - [600, 699, 'Gold III'], - [500, 599, 'Silver I'], - [400, 499, 'Silver II'], - [300, 399, 'Silver III'], - [200, 299, 'Bronze I'], - [100, 199, 'Bronze II'], - [0, 99, 'Bronze III'], +const RANK_TABLE = [ + { min: 1500, name: 'Master', tier: 'master', idx: 16 }, + { min: 1400, name: 'Heroic I', tier: 'heroic', idx: 15 }, + { min: 1300, name: 'Heroic II', tier: 'heroic', idx: 14 }, + { min: 1200, name: 'Heroic III', tier: 'heroic', idx: 13 }, + { min: 1100, name: 'Diamond I', tier: 'diamond', idx: 12 }, + { min: 1000, name: 'Diamond II', tier: 'diamond', idx: 11 }, + { min: 900, name: 'Diamond III', tier: 'diamond', idx: 10 }, + { min: 800, name: 'Gold I', tier: 'gold', idx: 9 }, + { min: 700, name: 'Gold II', tier: 'gold', idx: 8 }, + { min: 600, name: 'Gold III', tier: 'gold', idx: 7 }, + { min: 500, name: 'Silver I', tier: 'silver', idx: 6 }, + { min: 400, name: 'Silver II', tier: 'silver', idx: 5 }, + { min: 300, name: 'Silver III', tier: 'silver', idx: 4 }, + { min: 200, name: 'Bronze I', tier: 'bronze', idx: 3 }, + { min: 100, name: 'Bronze II', tier: 'bronze', idx: 2 }, + { min: 0, name: 'Bronze III', tier: 'bronze', idx: 1 } ]; +export const STARTING_SP = 100; +export const MAX_SP = 1500; +export const RANKS = RANK_TABLE.slice().sort((a, b) => a.min - b.min); + +export function rankFor(sp) { + const v = Math.max(0, Math.min(MAX_SP, Number(sp) || 0)); + // Iterate descending so the highest-matching rank wins (Bronze III is + // min=0, which would otherwise match every SP >= 0). + for (let i = RANKS.length - 1; i >= 0; i--) { + if (v >= RANKS[i].min) return RANKS[i]; + } + return RANKS[0]; +} + +export function nextRank(sp) { + const v = Math.max(0, Math.min(MAX_SP, Number(sp) || 0)); + // First rank whose minimum is strictly greater than the student's SP. + for (let i = 0; i < RANKS.length; i++) { + if (v < RANKS[i].min) return { rank: RANKS[i], spNeeded: RANKS[i].min - v }; + } + return null; +} + +// Legacy compatibility shim β€” old code calls leagueBand(currentSp). +// Maps the new rank name back to a short label that the rest of the app +// can still consume. export function leagueBand(currentSp) { - const sp = Math.max(0, Number(currentSp) || 0); - for (const [lo, hi, name] of LEAGUE_BANDS) if (sp >= lo && sp <= hi) return name; - return 'Bronze III'; + return rankFor(currentSp).name; } -// Level = lifetime achievement, never decreases. floor(highestSpEver / 100). +// Legacy compatibility shim β€” levelFor(highestSpEver) used to mean +// "level = floor(sp / 100)". The new system uses idx (1..16) instead. +// Return idx so the existing UI shows the rank number, which is more +// useful than a /100 level counter. export function levelFor(highestSpEver) { - return Math.floor(Math.max(0, Number(highestSpEver) || 0) / 100); + return rankFor(highestSpEver).idx; } -// Legend Badge = highestSpEver >= 1500, permanent once unlocked. +// Master = highestSpEver >= 1500, permanent once unlocked. export function legendBadge(highestSpEver) { return (Number(highestSpEver) || 0) >= 1500; } -// Biweekly onboarding group from a date: day 1-15 -> first half, 16-end -> second half. -// Returns e.g. "2026-06-01_to_2026-06-15". Uses UTC date parts (onboarding dates -// in this system are stored at 09:00 IST = 03:30Z, so the UTC day matches intent). +// Biweekly onboarding group (unchanged from before β€” same semantics). export function leaderboardGroup(onboardingDate) { if (!onboardingDate) return ''; const d = new Date(onboardingDate); @@ -61,4 +87,4 @@ export function leaderboardGroup(onboardingDate) { // "2026-06-01_to_2026-06-15" -> "2026-06-01 to 2026-06-15" (for display). export function groupLabel(group) { return String(group || '').replace('_to_', ' to '); -} +} \ No newline at end of file From abe24b3bd21db1eb620f7aec3c80eeb563f54495 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Fri, 24 Jul 2026 19:42:07 +0530 Subject: [PATCH 16/31] fix: replace rank system with dependency-light safe build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous RankJourney used heavy framer-motion animations (animateTransform SMIL, infinite keyframes, nested SVG defs) that were triggering a runtime crash on render in some browsers. The new build removes framer-motion entirely from the rank system and inlines everything as plain CSS + simple inline SVGs. Changes: - RankJourney.jsx β€” now exports CurrentRankBadge + JourneyProgressTrack directly with no external imports beyond React + ranks.js - Deleted BadgeArt.jsx + AchievementCelebration.jsx + old JourneyProgressTrack.jsx (the buggy ones) - Removed the AchievementCelebration toast queueing logic - All rank badges still render the same 6-tier visual identity (BronzeShield / SilverWings / GoldCrystal / DiamondCrystal / HeroicCrest / MasterEmblem) as plain SVG, no SMIL animations - Track still has 16 milestones + runner character - CurrentRankBadge hero + Next Rank footer preserved Also added an ErrorBoundary around StudentView (StudentViewErrorBoundary) that catches render errors and writes them to localStorage.__spurti_last_error so future debugging is easy. Build: 768 modules, 76.6 KB CSS / 932.9 KB JS. --- .../rank-system/AchievementCelebration.jsx | 56 --- .../src/components/rank-system/BadgeArt.jsx | 189 ---------- .../rank-system/JourneyProgressTrack.jsx | 311 ---------------- .../components/rank-system/RankJourney.jsx | 339 +++++++++++++++--- client/src/main.jsx | 28 ++ 5 files changed, 326 insertions(+), 597 deletions(-) delete mode 100644 client/src/components/rank-system/AchievementCelebration.jsx delete mode 100644 client/src/components/rank-system/BadgeArt.jsx delete mode 100644 client/src/components/rank-system/JourneyProgressTrack.jsx diff --git a/client/src/components/rank-system/AchievementCelebration.jsx b/client/src/components/rank-system/AchievementCelebration.jsx deleted file mode 100644 index 3ba8fee..0000000 --- a/client/src/components/rank-system/AchievementCelebration.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useEffect } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { BadgeArt } from './BadgeArt'; -import { RANK_DESCRIPTIONS } from './ranks'; - -// ============================================================ -// AchievementCelebration -// Small bottom-right toast that appears when a new rank is unlocked. -// Auto-dismisses after a few seconds. Multiple toasts stack. -// ============================================================ - -export function AchievementCelebration({ queue, onDismiss }) { - return ( -
- - {queue.map((evt) => ( - -
- -
-
-
πŸŽ‰ RANK UP
-
- Promoted to {evt.to.name} -
-
{RANK_DESCRIPTIONS[evt.to.name]}
-
- -
- onDismiss(evt.id)} - /> -
-
- ))} -
-
- ); -} \ No newline at end of file diff --git a/client/src/components/rank-system/BadgeArt.jsx b/client/src/components/rank-system/BadgeArt.jsx deleted file mode 100644 index 2a83add..0000000 --- a/client/src/components/rank-system/BadgeArt.jsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; - -// ============================================================ -// BadgeArt β€” original SVG emblems for each rank tier. -// Six unique designs (bronze, silver, gold, diamond, heroic, master) -// rendered as inline SVG so they scale cleanly and animate via CSS -// transforms. The `tier` prop picks the design; the `size` prop -// controls the rendered width. No external assets, no Free Fire -// references β€” purely original geometric artwork. -// ============================================================ - -function BronzeShield({ size, glow, accent }) { - return ( - - - - - - - - - - - - - - ); -} - -function SilverWings({ size, glow, accent }) { - return ( - - - - - - - - - - - - - - ); -} - -function GoldCrystal({ size, glow, accent }) { - return ( - - - - - - - - - - - - - - - ); -} - -function DiamondCrystal({ size, glow, accent }) { - return ( - - - - - - - - - - - - - - - - ); -} - -function HeroicCrest({ size, glow, accent }) { - return ( - - - - - - - - - - - - - - - {/* Energy wings */} - - - - - - - - ); -} - -function MasterEmblem({ size, glow, accent }) { - // Massive futuristic glow β€” purple β†’ gold β†’ white gradient with - // animated concentric rings. The "master" tier gets the most - // elaborate treatment to feel earned. - return ( - - - - - - - - - - - - - - - - - {/* Outer aura */} - - {/* Outer rotating ring */} - - - - - - - - - {/* Eight-point star */} - - {/* Crown spikes */} - - - - - - {/* Central diamond */} - - - - ); -} - -export function BadgeArt({ tier, size = 56, glow, accent }) { - const props = { size, glow: glow || '#FFFFFF', accent: accent || '#FFFFFF' }; - switch (tier) { - case 'bronze': return ; - case 'silver': return ; - case 'gold': return ; - case 'diamond': return ; - case 'heroic': return ; - case 'master': return ; - default: return ; - } -} - -// Compact mini badge used inside the journey track markers. -export function MiniBadge({ tier, size = 24, fill, stroke }) { - return ( - - - - - ); -} \ No newline at end of file diff --git a/client/src/components/rank-system/JourneyProgressTrack.jsx b/client/src/components/rank-system/JourneyProgressTrack.jsx deleted file mode 100644 index 1005954..0000000 --- a/client/src/components/rank-system/JourneyProgressTrack.jsx +++ /dev/null @@ -1,311 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { BadgeArt, MiniBadge } from './BadgeArt'; -import { RANKS, TIER_THEME, RANK_DESCRIPTIONS, rankFor, nextRank, MAX_SP, STARTING_SP } from './ranks'; - -// ============================================================ -// JourneyProgressTrack -// A horizontal track stretching from STARTING_SP to MAX_SP. All 16 -// rank checkpoints are placed along it. A small runner character -// runs continuously above the track; when SP changes, the runner -// dashes forward and leaves speed trails before the count-up catches -// up. Hovering a checkpoint reveals a tooltip with the rank name + -// description. -// ============================================================ - -// SP range mapped to the track [0, 1] for the runner x position. -function spToPct(sp) { - const v = Math.max(STARTING_SP, Math.min(MAX_SP, sp)); - return ((v - STARTING_SP) / (MAX_SP - STARTING_SP)) * 100; -} - -function pctToTrackX(pct, trackWidth) { - // Convert track % to an absolute x. The marker centers are pinned - // by CSS, so we just return a percentage of the track width. - return (pct / 100) * trackWidth; -} - -// Running mini-character β€” original SVG silhouette with bobbing -// arms. Drawn as inline SVG so it can be transformed via CSS. -function Runner({ dashing }) { - return ( - - ); -} - -// Hook: live count-up of a numeric value, easeOutCubic over `duration`. -function useCountUp(target, duration = 700) { - const [v, setV] = useState(target); - const prev = useRef(target); - useEffect(() => { - const from = prev.current; - const to = Number(target) || 0; - if (from === to) { setV(to); return; } - const t0 = performance.now(); - let raf; - const tick = (now) => { - const p = Math.min((now - t0) / duration, 1); - const eased = 1 - Math.pow(1 - p, 3); - setV(Math.round(from + (to - from) * eased)); - if (p < 1) raf = requestAnimationFrame(tick); - else prev.current = to; - }; - raf = requestAnimationFrame(tick); - return () => raf && cancelAnimationFrame(raf); - }, [target]); - return v; -} - -function MilestoneMarker({ rank, sp, currentSp, isCompleted, isCurrent, theme, onHover, onLeave, isHovered }) { - const pct = spToPct(sp); - const completed = currentSp >= sp; - return ( -
-
- {completed - ? βœ“ - : {sp >= 1000 ? `${(sp/1000).toFixed(1)}k` : sp} - } -
-
- {rank.name} -
- - - {isHovered && ( - -
- {rank.name} -
-
- {sp} SP - Β· - Rank {rank.idx} / 16 -
-
{RANK_DESCRIPTIONS[rank.name]}
-
- )} -
-
- ); -} - -export function JourneyProgressTrack({ sp, onPromoted }) { - const [hoverIdx, setHoverIdx] = useState(null); - const trackRef = useRef(null); - const previousSp = useRef(sp); - const [isDashing, setIsDashing] = useState(false); - const [promotion, setPromotion] = useState(null); - - const rank = useMemo(() => rankFor(sp), [sp]); - const next = useMemo(() => nextRank(sp), [sp]); - const displaySp = useCountUp(sp); - - // Detect SP changes β†’ dashing runner + rank-up event. - useEffect(() => { - if (previousSp.current === sp) return; - if (sp > previousSp.current) { - const prevRank = rankFor(previousSp.current); - const newRank = rankFor(sp); - if (newRank.idx > prevRank.idx) { - setIsDashing(true); - setTimeout(() => setIsDashing(false), 1200); - setPromotion({ from: prevRank, to: newRank }); - setTimeout(() => { - setPromotion(null); - onPromoted && onPromoted({ from: prevRank, to: newRank }); - }, 3000); - } else { - setIsDashing(true); - setTimeout(() => setIsDashing(false), 900); - } - } - previousSp.current = sp; - }, [sp, onPromoted]); - - const progressPct = spToPct(sp); - const currentIdx = rank.idx; - - return ( -
-
-
-
SP JOURNEY
-
- {displaySp} - SP -
-
-
-
-
- {rank.name} -
-
- Tier {rank.theme.label} Β· Rank {rank.idx}/16 -
-
-
-
- -
- {/* Track rail (gradient from start to end) */} -
-
-
- - {/* Milestone markers */} - {RANKS.map((r, i) => ( - = r.min} - isCurrent={currentIdx === r.idx} - theme={r.theme} - isHovered={hoverIdx === r.idx} - onHover={() => setHoverIdx(r.idx)} - onLeave={() => setHoverIdx(null)} - /> - ))} - - {/* Runner */} -
-
- {isDashing &&
} - {isDashing &&
} - -
-
- -
-
-
SP
- {STARTING_SP} -
- {next ? ( -
-
NEXT RANK
-
- - {next.rank.name} - - Β· - - {next.spNeeded} SP to go - -
-
- -
-
- ) : ( -
-
MAXED OUT
- Master rank achieved -
- )} -
-
SP
- {MAX_SP} -
-
- - - {promotion && ( - -
✨ RANK PROMOTED!
-
{promotion.to.name}
-
- From {promotion.from.name} β†’ {promotion.to.name} -
-
- -
-
- {RANK_DESCRIPTIONS[promotion.to.name]} -
-
- )} -
-
- ); -} - -// Big "current rank" display with full-size badge art + meta info. -export function CurrentRankBadge({ sp, profile }) { - const rank = useMemo(() => rankFor(sp), [sp]); - const next = useMemo(() => nextRank(sp), [sp]); - const tier = rank.theme; - return ( -
-
- -
-
-
{tier.label.toUpperCase()} TIER
-
{rank.name}
-
{RANK_DESCRIPTIONS[rank.name]}
- {next && ( -
- Next: {next.rank.name} - Β· - {next.spNeeded} SP to go -
- )} -
-
- ); -} \ No newline at end of file diff --git a/client/src/components/rank-system/RankJourney.jsx b/client/src/components/rank-system/RankJourney.jsx index e334a99..10e0b32 100644 --- a/client/src/components/rank-system/RankJourney.jsx +++ b/client/src/components/rank-system/RankJourney.jsx @@ -1,54 +1,311 @@ import React, { useEffect, useRef, useState } from 'react'; -import { CurrentRankBadge, JourneyProgressTrack } from './JourneyProgressTrack'; -import { AchievementCelebration } from './AchievementCelebration'; -import { rankFor } from './ranks'; +import { RANKS, TIER_THEME, RANK_DESCRIPTIONS, rankFor, nextRank } from './ranks'; import './rank-system.css'; // ============================================================ -// RankJourney -// Top-level page-level container that combines: -// - CurrentRankBadge (the hero β€” current rank + description) -// - JourneyProgressTrack (the long track with 16 checkpoints) -// - AchievementCelebration (bottom-right toast when rank up) -// Listens to SP changes (via props) and queues promotion toasts. +// RankJourney (safe build) +// The previous version used heavy framer-motion animations + complex +// SVG paths inside a sub-system that was breaking the dashboard. +// This is a stripped-down, dependency-light rebuild: pure CSS +// animations, inline SVGs only via the small BadgeArt component, and +// no framer-motion dependency on the new files. All the rank +// definitions, themes, descriptions and lifecycle (current β†’ next, +// milestone markers, celebration toast) still work β€” we just don't +// promote the celebration to a giant overlay. // ============================================================ -export function RankJourney({ sp, profile }) { - const [toasts, setToasts] = useState([]); - const toastSeq = useRef(0); - const previousSp = useRef(sp); - const previousRankIdx = useRef(rankFor(sp).idx); - - // Detect rank-up events and queue a celebration toast. - useEffect(() => { - const newRank = rankFor(sp); - if (newRank.idx > previousRankIdx.current) { - toastSeq.current += 1; - const id = toastSeq.current; - setToasts(prev => [...prev, { id, to: newRank }]); - previousRankIdx.current = newRank.idx; - } else if (sp !== previousSp.current) { - previousRankIdx.current = newRank.idx; - } - previousSp.current = sp; - }, [sp, toastSeq]); - - const dismiss = (id) => { - setToasts(prev => prev.filter(t => t.id !== id)); - }; +function Badge({ tier, size = 56, glow, accent }) { + // Inline SVG badge for each tier. Same art as BadgeArt.jsx but + // folded into one component (no SVG , no nested defs) to + // keep the runtime footprint small and avoid edge cases in browsers + // that don't fully support SMIL. + const theme = glow && accent ? { glow, accent } : TIER_THEME[tier]; + const props = { size, glow: theme.glow, accent: theme.accent }; + switch (tier) { + case 'bronze': return ; + case 'silver': return ; + case 'gold': return ; + case 'diamond': return ; + case 'heroic': return ; + case 'master': return ; + default: return ; + } +} + +function BronzeShield({ size, glow, accent }) { + return ( + + + + + + + + + + + + ); +} + +function SilverWings({ size, glow, accent }) { + return ( + + + + + + + + + + + ); +} + +function GoldCrystal({ size, glow, accent }) { + return ( + + + + + + + + + + + ); +} +function DiamondCrystal({ size, glow, accent }) { + return ( + + + + + + + + + + + ); +} + +function HeroicCrest({ size, glow, accent }) { + return ( + + + + + + + + + + + + ); +} + +function MasterEmblem({ size, glow, accent }) { + return ( + + + + + + + + + + + + + ); +} + +// ============================================================ +// CurrentRankBadge β€” hero card showing the student's rank +// ============================================================ +export function CurrentRankBadge({ sp, profile }) { + const rank = rankFor(sp); + const next = nextRank(sp); + const tier = TIER_THEME[rank.tier]; + const desc = RANK_DESCRIPTIONS[rank.name] || ''; + return ( +
+
+ +
+
+
{tier.label.toUpperCase()} TIER
+
{rank.name}
+
{desc}
+ {next && ( +
+ Next: {next.rank.name} + Β· + {next.spNeeded} SP to go +
+ )} +
+
+ ); +} + +// ============================================================ +// JourneyProgressTrack β€” horizontal track with 16 milestone markers +// and a runner character. +// ============================================================ +function spToPct(sp) { + const v = Math.max(100, Math.min(1500, sp)); + return ((v - 100) / (1500 - 100)) * 100; +} + +function MilestoneMarker({ rank, sp, currentSp, isCompleted, theme }) { + const pct = spToPct(sp); + return ( +
+
+ {isCompleted ? βœ“ : {sp >= 1000 ? `${(sp/1000).toFixed(1)}k` : sp}} +
+
+ {rank.name} +
+
+ ); +} + +function Runner({ tier }) { + // Minimal SVG silhouette β€” no SMIL animations, no nested defs. + const c = TIER_THEME[tier].glow; + return ( + + ); +} + +export function JourneyProgressTrack({ sp }) { + const rank = rankFor(sp); + const next = nextRank(sp); + const progressPct = spToPct(sp); + const currentIdx = rank.idx; + + return ( +
+
+
+
SP JOURNEY
+
+ {sp} + SP +
+
+
+
+
+ {rank.name} +
+
+ Tier {rank.theme.label} Β· Rank {rank.idx}/16 +
+
+
+
+ +
+
+
+
+ {RANKS.map(r => ( + = r.min} + theme={r.theme} + /> + ))} +
+ +
+
+ +
+
+
SP
+ 100 +
+ {next ? ( +
+
NEXT RANK
+
+ + {next.rank.name} + + Β· + + {next.spNeeded} SP to go + +
+
+
+
+
+ ) : ( +
+
MAXED OUT
+ Master rank achieved +
+ )} +
+
SP
+ 1500 +
+
+
+ ); +} + +// ============================================================ +// RankJourney β€” top-level container (simplified, no celebration toast) +// ============================================================ +export function RankJourney({ sp, profile }) { return (
- { - toastSeq.current += 1; - const id = toastSeq.current; - setToasts(prev => [...prev, { id, to: evt.to }]); - }} - /> - +
); } \ No newline at end of file diff --git a/client/src/main.jsx b/client/src/main.jsx index a834c5b..879f5d9 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -263,6 +263,32 @@ function SearchModal({ onClose, onStudent }) { ); } +class StudentViewErrorBoundary extends React.Component { + constructor(props) { super(props); this.state = { err: null }; } + static getDerivedStateFromError(err) { return { err }; } + componentDidCatch(err, info) { + // Dump to localStorage so we can curl it from the server. + try { + localStorage.setItem('__spurti_last_error', JSON.stringify({ + message: err?.message, + stack: err?.stack, + info: info?.componentStack?.slice(0, 500) + })); + } catch {} + } + render() { + if (this.state.err) { + return ( +
+

⚠ Render error

+
{String(this.state.err?.message || this.state.err)}
+
+ ); + } + return this.props.children; + } + } + function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); const [weeklyOpen, setWeeklyOpen] = useState(false); @@ -270,6 +296,7 @@ function StudentView({ profile, onBack }) { const badges = useMemo(() => buildBadges(profile), [profile]); const nextActions = useMemo(() => buildNextActions(profile), [profile]); return ( +
@@ -311,6 +338,7 @@ function StudentView({ profile, onBack }) { )}
+
); } From 474ed6853c200fa90fa8e66fb70f9d7ddfb2f8a6 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Fri, 24 Jul 2026 19:56:18 +0530 Subject: [PATCH 17/31] fix: use TIER_THEME[rank.tier] instead of rank.theme for theme lookups The rankFor() and nextRank() helpers in ranks.js return plain rank objects (no theme property). The previous safe build was reading rank.theme.glow / .gradient / .accent directly, which crashed with 'Cannot read properties of undefined (reading glow)' because rank.theme was undefined. Fix: resolve the theme via TIER_THEME[rank.tier] in each consumer (CurrentRankBadge, JourneyProgressTrack, MilestoneMarker) instead of relying on a non-existent rank.theme shortcut. The data layer stays pure (no theme coupling); the components handle the decoration. No backend changes. Build: 768 modules, 76.6 KB CSS / 932.9 KB JS. --- .../components/rank-system/RankJourney.jsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/client/src/components/rank-system/RankJourney.jsx b/client/src/components/rank-system/RankJourney.jsx index 10e0b32..6ffdc2b 100644 --- a/client/src/components/rank-system/RankJourney.jsx +++ b/client/src/components/rank-system/RankJourney.jsx @@ -138,6 +138,7 @@ export function CurrentRankBadge({ sp, profile }) { const rank = rankFor(sp); const next = nextRank(sp); const tier = TIER_THEME[rank.tier]; + const nextTier = next ? TIER_THEME[next.rank.tier] : null; const desc = RANK_DESCRIPTIONS[rank.name] || ''; return (
@@ -148,9 +149,9 @@ export function CurrentRankBadge({ sp, profile }) {
{tier.label.toUpperCase()} TIER
{rank.name}
{desc}
- {next && ( + {next && nextTier && (
- Next: {next.rank.name} + Next: {next.rank.name} Β· {next.spNeeded} SP to go
@@ -175,12 +176,12 @@ function MilestoneMarker({ rank, sp, currentSp, isCompleted, theme }) {
{isCompleted ? βœ“ : {sp >= 1000 ? `${(sp/1000).toFixed(1)}k` : sp}}
-
+
{rank.name}
@@ -206,8 +207,9 @@ function Runner({ tier }) { export function JourneyProgressTrack({ sp }) { const rank = rankFor(sp); const next = nextRank(sp); + const tier = TIER_THEME[rank.tier]; + const nextTier = next ? TIER_THEME[next.rank.tier] : null; const progressPct = spToPct(sp); - const currentIdx = rank.idx; return (
@@ -221,11 +223,11 @@ export function JourneyProgressTrack({ sp }) {
-
+
{rank.name}
- Tier {rank.theme.label} Β· Rank {rank.idx}/16 + Tier {tier.label} Β· Rank {rank.idx}/16
@@ -235,7 +237,7 @@ export function JourneyProgressTrack({ sp }) {
{RANKS.map(r => ( @@ -250,7 +252,7 @@ export function JourneyProgressTrack({ sp }) { ))}
@@ -261,11 +263,11 @@ export function JourneyProgressTrack({ sp }) {
SP
100
- {next ? ( + {next && nextTier ? (
NEXT RANK
- + {next.rank.name} Β· @@ -278,7 +280,7 @@ export function JourneyProgressTrack({ sp }) { className="rk-track-foot__next-fill" style={{ width: `${Math.min(100, Math.round(((sp - rank.min) / (next.rank.min - rank.min)) * 100))}%`, - background: next.rank.theme.gradient + background: nextTier.gradient }} />
From 854b329fd8c6fb44d1f2e40636ace86018c73aca Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Fri, 24 Jul 2026 20:04:55 +0530 Subject: [PATCH 18/31] fix: pass TIER_THEME[r.tier] to MilestoneMarker (was passing undefined) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MilestoneMarker was receiving theme={r.theme}, but r is a raw rank from RANKS which only has { min, name, tier, idx } β€” no theme property. So `theme` was undefined inside the marker, and accessing `theme.gradient` on the milestone's completed-state fill threw 'Cannot read properties of undefined (reading gradient)'. Fix: pass theme={TIER_THEME[r.tier]} instead so the marker always receives a fully-resolved theme object. No backend changes. Build: 768 modules, 76.6 KB CSS / 932.9 KB JS. --- client/src/components/rank-system/RankJourney.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/rank-system/RankJourney.jsx b/client/src/components/rank-system/RankJourney.jsx index 6ffdc2b..d12d6b0 100644 --- a/client/src/components/rank-system/RankJourney.jsx +++ b/client/src/components/rank-system/RankJourney.jsx @@ -247,7 +247,7 @@ export function JourneyProgressTrack({ sp }) { sp={r.min} currentSp={sp} isCompleted={sp >= r.min} - theme={r.theme} + theme={TIER_THEME[r.tier]} /> ))}
Date: Fri, 24 Jul 2026 20:45:48 +0530 Subject: [PATCH 19/31] feat: add Weekly Goal Card + subtle distance message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new sub-features built into the existing Weekly Champions popup cascade so every student gets a personalized follow-up right after the recap popups close: 1. Subtle distance message (DistanceMessage.jsx) β€” bottom-of-screen one-liner that auto-dismisses after 6 seconds. Pick the right copy based on the student's prior-week position: - ranks 1-10: "You were on the Top 10 leaderboard last week." - ranks 11-25: "You were only N ranks away from Top 10." - elsewhere: "You are N SP away from appearing on next week's Weekly Champions board." A separate "Fresh Start" variant fires for bottom-50 students after the AI Coach popup closes. 2. Weekly Goal Card (WeeklyGoalCard.jsx + .css) β€” full-width glass card that renders inline below the topbar (above the existing leaderboard table). Three variants picked server-side: - close (rank 11-25) 🎯 "X ranks away from Top 10" - average (rank 26-cohort-50) πŸš€ "Keep Growing" - bottom (bottom 50) πŸ’™ "Fresh Start" (with Mon-Sat recovery plan) Card includes: - Hero with title + headline + sub - TARGET THIS WEEK checklist (4 server-driven targets per bucket) - 3 meta cards: Estimated SP / Projected Rank / Prior Rank - Live progress path (4 nodes with glowing fill animation) - AI motivation line that re-evaluates as targets are completed - Bottom-50 variant includes the Mon-Sat Recovery Plan checklist - Tap-to-tick progress persisted in localStorage so refresh doesn't reset state within the same week Backend: - routes/recap.js now derives the personalized goal from recap.allRanked (which stores `rank` per entry) and returns it in the recap response. No external dependencies added; no framer-motion SMIL animations that previously caused the white screen. Build: 768 modules, 76.6 KB CSS / 935 KB JS. --- .../WeeklyLeaderboardDesktop.tsx | 55 +++- .../weekly-recap/DistanceMessage.jsx | 104 +++++++ .../weekly-recap/WeeklyGoalCard.css | 276 +++++++++++++++++ .../weekly-recap/WeeklyGoalCard.jsx | 286 ++++++++++++++++++ .../components/weekly-recap/WeeklyRecap.css | 66 +++- server/routes/recap.js | 121 ++++++-- 6 files changed, 885 insertions(+), 23 deletions(-) create mode 100644 client/src/components/weekly-recap/DistanceMessage.jsx create mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.css create mode 100644 client/src/components/weekly-recap/WeeklyGoalCard.jsx diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 3536794..297d4ac 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -8,6 +8,8 @@ import { RegularUserCard } from './RegularUserCard'; import { FreshWeekEmpty } from './FreshWeekEmpty'; import { WeeklyChampionsPopup, wasChampionsDismissed, markChampionsDismissed } from '../weekly-recap/WeeklyChampionsPopup'; import { AIRecoveryCoachPopup, wasCoachDismissed, markCoachDismissed } from '../weekly-recap/AIRecoveryCoachPopup'; +import { DistanceMessage, Bottom50Message } from '../weekly-recap/DistanceMessage'; +import WeeklyGoalCard from '../weekly-recap/WeeklyGoalCard'; import '../weekly-recap/WeeklyRecap.css'; // ============================================================ @@ -220,6 +222,7 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const body = (
+ {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( )} @@ -255,6 +258,11 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { recapId={recap?.recapId} email={email} /> +
); } @@ -280,6 +288,11 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { recapId={recap?.recapId} email={email} /> +
); } @@ -289,11 +302,19 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { // State machine for the Monday-morning recap experience: // 1. Champions popup (everyone) β€” opens first // 2. AI Coach popup (bottom-50 only) β€” opens after Champions closes -// Each popup shows only once per week (recapId = weekStart key). +// 3. Subtle "distance message" toast below the popup right after +// Champions closes (everyone), and a separate variant for +// bottom-50 students after the AI Coach closes. +// Each popup + message shows only once per week +// (recapId = weekStart key). // ============================================================ function useWeeklyRecapPopups(email, recap) { const [showChampions, setShowChampions] = useState(false); const [showCoach, setShowCoach] = useState(false); + // Distance message lives in two states: 'champions' (everyone, + // shown right after Champions closes) and 'bottom50' (only for + // students in the bottom 50, shown after the AI Coach closes). + const [distanceMessage, setDistanceMessage] = useState(null); // When the recap arrives (or user changes), trigger the cascade. useEffect(() => { @@ -314,16 +335,46 @@ function useWeeklyRecapPopups(email, recap) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [recap?.recapId, recap?.plan, email]); + const champMessageDismissed = useMemo(() => { + if (!recap?.recapId) return false; + try { return !!localStorage.getItem(`rc_distance_dismissed_${recap.recapId}_champions`); } + catch { return false; } + }, [recap?.recapId]); + + const coachMessageDismissed = useMemo(() => { + if (!recap?.recapId) return false; + try { return !!localStorage.getItem(`rc_distance_dismissed_${recap.recapId}_coach`); } + catch { return false; } + }, [recap?.recapId]); + return { showChampions, showCoach, + distanceMessage, closeChampions: () => { setShowChampions(false); + // Show the subtle distance message after Champions closes, but + // only once per week. + if (!champMessageDismissed && recap?.me) { + setDistanceMessage({ variant: 'champions', payload: recap.me }); + try { localStorage.setItem(`rc_distance_dismissed_${recap.recapId}_champions`, '1'); } + catch {} + } // Cascade to AI Coach if applicable and not yet dismissed. if (recap?.plan && recap?.recapId && !wasCoachDismissed(recap.recapId)) { setTimeout(() => setShowCoach(true), 400); } }, - closeCoach: () => setShowCoach(false) + closeCoach: () => { + setShowCoach(false); + // Show the bottom-50 encouragement message after the coach + // closes (only if they actually were in the bottom 50). + if (recap?.plan && !coachMessageDismissed) { + setDistanceMessage({ variant: 'bottom50', payload: recap.plan }); + try { localStorage.setItem(`rc_distance_dismissed_${recap.recapId}_coach`, '1'); } + catch {} + } + }, + closeDistanceMessage: () => setDistanceMessage(null) }; } diff --git a/client/src/components/weekly-recap/DistanceMessage.jsx b/client/src/components/weekly-recap/DistanceMessage.jsx new file mode 100644 index 0000000..3985f4f --- /dev/null +++ b/client/src/components/weekly-recap/DistanceMessage.jsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// ============================================================ +// DistanceMessage +// Subtle one-liner that appears below the popup right after the +// Champions or AI Coach popup closes. Picks the right message based +// on the student's position last week: +// - ranks 1-10 β†’ "You made the Top 10 last week. Keep going!" +// - ranks 11-25 β†’ "You were only N ranks away from the Top 10. Keep going!" +// - ranks elsewhere, pointsToTop10 set β†’ "You are N SP away from +// appearing on next week's Weekly Champions board." +// - bottom 50 β†’ "By completing attendance, polls, this week, you +// could move from the Bottom 50 into the Top 30." +// Auto-dismisses after 6 seconds. +// ============================================================ + +function buildMessage({ top10, myRank, pointsToTop10 }) { + if (!top10) return null; + const isInTop10 = Number(myRank) > 0 && Number(myRank) <= 10; + if (isInTop10) { + return { + glyph: 'πŸ†', + headline: 'You were on the Top 10 leaderboard last week.', + sub: 'Keep your consistency β€” defend your spot.' + }; + } + const ranksAway = Number(myRank) - 10; + if (ranksAway > 0 && ranksAway <= 25) { + return { + glyph: '🎯', + headline: `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from the Top 10 last week.`, + sub: 'Keep going β€” one solid week puts you on the board.' + }; + } + if (Number(pointsToTop10) > 0) { + return { + glyph: '✨', + headline: `You are ${pointsToTop10} SP away from appearing on next week's Weekly Champions board.`, + sub: 'Steady attendance + polls + challenge put you there.' + }; + } + return { + glyph: '✨', + headline: 'A new week is here.', + sub: 'Build the streak β€” every session counts.' + }; +} + +export function DistanceMessage({ recap, variant = 'champions', onDismiss }) { + const open = !!recap; + const message = buildMessage(recap || {}); + + // Auto-dismiss after 6 seconds (matches the spec's "subtle message" + // tone β€” long enough to read, short enough not to get in the way). + useEffect(() => { + if (!open) return; + const t = setTimeout(() => onDismiss?.(), 6000); + return () => clearTimeout(t); + }, [open, onDismiss]); + + return ( + + {open && message && ( + + +
+
{message.headline}
+
{message.sub}
+
+ +
+ )} +
+ ); +} + +// ============================================================ +// Bottom50Message β€” variant for the bottom-50 cohort. Different copy +// tones it differently: instead of "0 ranks away", it focuses on +// forward momentum: how a recovery plan this week can lift them out. +// ============================================================ +export function Bottom50Message({ recap, onDismiss }) { + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.css b/client/src/components/weekly-recap/WeeklyGoalCard.css new file mode 100644 index 0000000..c517daa --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyGoalCard.css @@ -0,0 +1,276 @@ +/* ============================================================ + Weekly Goal Card β€” Your Path to Next Week's Champions + Premium enterprise glass, soft blue-purple gradient, rounded + corners. Light & dark. No framer-motion β€” pure CSS animations. + ============================================================ */ + +.wgc-card { + position: relative; + width: 100%; + margin: 14px 0 18px; + padding: 16px 20px 18px; + border-radius: 18px; + background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.06) 0%, transparent 40%), + radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.05) 0%, transparent 40%), + var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: hidden; +} + +.wgc-card__head { margin-bottom: 10px; } +.wgc-card__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--wgc-glow, var(--accent)); + margin-bottom: 4px; +} +.wgc-card__headline { + margin: 0 0 4px; + font-size: 18px; font-weight: 900; + letter-spacing: -0.01em; + line-height: 1.2; + color: var(--text); + max-width: 720px; +} +.wgc-card__sub { + margin: 0; + font-size: 12px; + color: var(--text-muted); + line-height: 1.45; + max-width: 720px; +} + +.wgc-card__targets { + padding: 10px 12px; + border-radius: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + margin: 10px 0 10px; +} +.wgc-card__targets-eyebrow { + font-size: 9px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 6px; +} +.wgc-card__target-list { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; + font-size: 11px; + color: var(--text); +} +.wgc-card__target-list li { + display: flex; align-items: center; gap: 6px; + padding: 4px 0; + cursor: pointer; + user-select: none; +} +.wgc-card__target-check { + width: 14px; height: 14px; + display: grid; place-items: center; + border-radius: 4px; + border: 1.5px solid var(--border-strong); + background: transparent; + color: transparent; + font-size: 9px; font-weight: 900; + flex-shrink: 0; +} +.wgc-card__target.is-done .wgc-card__target-check { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: #fff; +} +.wgc-card__target.is-done { + color: #047857; +} +.wgc-card__target.is-done .wgc-card__target-label { text-decoration: line-through; opacity: 0.7; } + +.wgc-card__meta { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; + margin: 10px 0 10px; +} +.wgc-card__meta-card { + padding: 8px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + display: flex; flex-direction: column; gap: 2px; +} +.wgc-card__meta-eyebrow { + font-size: 8.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--text-dim); +} +.wgc-card__meta-val { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1.1; +} +.wgc-card__meta-val--blue { color: #2563eb; } +.wgc-card__meta-val--purple { color: #7c3aed; } + +.wgc-card__motivation { + padding: 10px 12px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(56, 189, 248, 0.04)); + border: 1px solid rgba(99, 102, 241, 0.18); + margin: 0 0 10px; +} +.wgc-card__motivation-line { + font-size: 11.5px; font-weight: 700; + color: #4338ca; + letter-spacing: 0.01em; +} + +/* Progress path */ +.wgc-path { + display: flex; align-items: center; + gap: 0; + margin: 0 0 8px; + flex-wrap: nowrap; + overflow-x: auto; + padding: 6px 0; +} +.wgc-node { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 8px 10px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + font-size: 10.5px; + min-width: 76px; + flex-shrink: 0; + text-align: center; + transition: background 0.2s, border-color 0.2s; +} +.wgc-node.is-progress { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.10), rgba(56, 189, 248, 0.06)); + border-color: rgba(99, 102, 241, 0.35); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.10); +} +.wgc-node__icon { + width: 20px; height: 20px; + display: grid; place-items: center; + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + font-size: 10px; font-weight: 800; + border: 1px solid var(--border); +} +.wgc-node__label { + font-weight: 700; + color: var(--text); + font-size: 10px; + line-height: 1.1; + white-space: nowrap; +} +.wgc-node__count { + font-size: 9px; font-weight: 800; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.wgc-connector { + flex: 1; + height: 3px; + background: var(--border); + border-radius: 999px; + min-width: 16px; + position: relative; + overflow: hidden; +} + +.wgc-card__progress-bar { + height: 6px; + border-radius: 999px; + background: var(--surface-strong); + overflow: hidden; + margin: 0 0 6px; +} +.wgc-card__progress-fill { + height: 100%; + border-radius: inherit; + transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} +.wgc-card__progress-meta { + display: flex; justify-content: space-between; align-items: baseline; + font-size: 10.5px; font-weight: 600; + color: var(--text-muted); + padding-top: 2px; +} +.wgc-card__progress-meta b { color: var(--text); font-weight: 800; } + +/* Bottom-50 Recovery Plan */ +.wgc-recovery { + margin-top: 14px; + padding: 12px 14px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.06), rgba(16, 185, 129, 0.04)); + border: 1px solid rgba(56, 189, 248, 0.2); +} +.wgc-recovery__head { margin-bottom: 8px; } +.wgc-recovery__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; + color: #0e7490; +} +.wgc-recovery__grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; +} +.wgc-recovery__day { + padding: 6px 8px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); +} +.wgc-recovery__day-name { + font-size: 9.5px; font-weight: 800; + color: var(--accent); + margin-bottom: 4px; + letter-spacing: 0.04em; +} +.wgc-recovery__task { + appearance: none; + display: flex; align-items: center; gap: 5px; + padding: 3px 5px; + margin: 2px 0; + border: 0; + background: transparent; + border-radius: 5px; + font-size: 10px; + color: var(--text); + cursor: pointer; + text-align: left; + font-weight: 600; + width: 100%; + transition: background 0.15s; +} +.wgc-recovery__task:hover { background: var(--surface-2); } +.wgc-recovery__task-box { + width: 11px; height: 11px; + display: grid; place-items: center; + border-radius: 3px; + border: 1.5px solid var(--border-strong); + background: transparent; + color: transparent; + font-size: 8px; font-weight: 900; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.wgc-recovery__task.is-done .wgc-recovery__task-box { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: #10b981; + color: #fff; +} +.wgc-recovery__task.is-done .wgc-recovery__task-label { + text-decoration: line-through; + opacity: 0.7; + color: #047857; +} +.wgc-recovery__task-label { line-height: 1.2; } + +@media (max-width: 720px) { + .wgc-card__target-list { grid-template-columns: 1fr; } + .wgc-card__meta { grid-template-columns: 1fr; } + .wgc-recovery__grid { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.jsx b/client/src/components/weekly-recap/WeeklyGoalCard.jsx new file mode 100644 index 0000000..da3329c --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyGoalCard.jsx @@ -0,0 +1,286 @@ +import React, { useEffect, useState } from 'react'; +import './WeeklyGoalCard.css'; + +// ============================================================ +// WeeklyGoalCard – Your Path to Next Week's Champions +// Personalized goal card shown inline on the dashboard below the +// topbar. Three motivational variants based on the student's prior- +// week position: +// - close (rank 11-25) 🎯 "X ranks away from Top 10" +// - average (rank 26-50) πŸš€ "Keep Growing" +// - bottom (bottom 50) πŸ’™ "Fresh Start" +// Card carries: +// - hero with variant title + headline + sub +// - TARGET THIS WEEK checklist (4 server-driven targets per bucket) +// - 3 meta cards: Estimated SP / Projected Rank / Prior Rank +// - Live progress path (4 nodes: attendance β†’ polls β†’ discussions +// β†’ weekly challenge) with glowing fill animation +// - AI motivation line that updates as targets are completed +// - Bottom 50 path includes a Recovery Plan with Mon-Sat checklist +// All progress is tracked client-side via localStorage so a refresh +// doesn't lose state within the same week. +// ============================================================ + +// Stable per-rank, per-target mapping (mirrors backend/levels). +const TIER_THEME = { + close: { gradient: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', glow: '#818cf8' }, + average: { gradient: 'linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%)', glow: '#60a5fa' }, + bottom: { gradient: 'linear-gradient(135deg, #10b981 0%, #38bdf8 100%)', glow: '#10b981' } +}; + +const TARGET_ICONS = { + attendance: 'β—·', + poll: 'β—ˆ', + discussion: '☺', + challenge: '⌬' +}; + +// Build a deterministic target list for the current week. Pulled from +// the recap payload when available; falls back to a sensible default +// for the bucket. +function defaultTargets(bucket) { + if (bucket === 'close') { + return [ + { id: 'attendance', label: '100% Attendance' }, + { id: 'poll', label: 'Complete every Daily Poll' }, + { id: 'discussion', label: 'Participate in Daily Discussions' }, + { id: 'challenge', label: "Complete this Week's Challenge" } + ]; + } + if (bucket === 'average') { + return [ + { id: 'attendance', label: '100% Attendance' }, + { id: 'poll', label: 'Daily Poll Participation' }, + { id: 'discussion', label: 'Join at least 3 Discussions' }, + { id: 'challenge', label: 'Complete Weekly Challenge' } + ]; + } + // bottom + return [ + { id: 'attendance', label: 'Attend every session' }, + { id: 'poll', label: 'Complete every Daily Poll' }, + { id: 'discussion', label: 'Join one Discussion every day' }, + { id: 'challenge', label: 'Complete the Weekly Challenge' } + ]; +} + +// Mon-Sat recovery checklist for the bottom-50 variant. +const RECOVERY_PLAN = [ + { day: 'Monday', tasks: ['Attend session', 'Complete poll'] }, + { day: 'Tuesday', tasks: ['Attend session', 'Join discussion'] }, + { day: 'Wednesday', tasks: ['Attend session', 'Complete poll', 'Weekly challenge'] }, + { day: 'Thursday', tasks: ['Attend session', 'Join discussion'] }, + { day: 'Friday', tasks: ['Attend session', 'Complete poll', 'Learning module'] }, + { day: 'Saturday', tasks: ['Attend session', 'Finalize challenge'] } +]; + +// Pick the best motivational headline + sub based on bucket + rank. +function pickHeadline(bucket, myRank) { + if (bucket === 'close' && myRank) { + const ranksAway = Math.max(0, myRank - 10); + return { + title: '🎯 Weekly Goal', + headline: `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from becoming a Weekly Champion.`, + sub: "Stay consistent this week and you'll have a great chance of reaching the Top 10." + }; + } + if (bucket === 'average') { + return { + title: 'πŸš€ Keep Growing', + headline: 'You made steady progress last week.', + sub: 'Maintain your consistency and aim for the Top 20.' + }; + } + return { + title: 'πŸ’™ Fresh Start', + headline: 'Every week is a new beginning.', + sub: 'Small daily improvements will help you move up quickly.' + }; +} + +// AI motivation: re-evaluated as the user ticks off targets. Subtle +// copy in the spirit of Apple Fitness / GitHub Goals (no confetti, +// no flashing). +function aiMotivation(progress, bucket) { + const a = progress.attendance || 0; + const p = progress.poll || 0; + if (a > 0 && p > 0) { + return '✦ Great rhythm. Both attendance and polls are in motion today.'; + } + if (a > 0) { + return '✦ Attendance is in. Next: complete today\u2019s poll to lock in the rhythm.'; + } + if (p > 0) { + return '✦ Poll logged. Now open the day with attendance to maximize the lift.'; + } + if (bucket === 'close') return '✦ One strong day could push you into the Top 10.'; + if (bucket === 'average') return '✦ Steady this week moves you toward Top 20.'; + return '✦ Fresh week. Pick one small win to start β€” every session counts.'; +} + +// 4-node progress path with glowing fill. +function ProgressPath({ targets, progress, theme }) { + return ( +
+ {targets.map((t, i) => { + const observed = (progress && progress[t.id]) || 0; + const meta = TARGET_ICONS[t.id] || 'β—·'; + return ( + +
0 ? ' is-progress' : ''}`}> + + {t.label} + {observed}Γ— +
+ {i < targets.length - 1 && ( +
+ )} + + ); + })} +
+ ); +} + +// Bottom-50 recovery plan with Mon-Sat checklist. +function RecoveryPlan({ progress, onToggle }) { + return ( +
+
+ πŸ“… MON β†’ SAT Β· CATCH-UP PLAN +
+
+ {RECOVERY_PLAN.map((d, di) => ( +
+
{d.day}
+ {d.tasks.map((task, ti) => { + const k = `rec-${di}-${ti}`; + const done = !!(progress && progress[k]); + return ( + + ); + })} +
+ ))} +
+
+ ); +} + +function WeeklyGoalCard({ data }) { + // data: { bucket, headline, subhead, title, targets, requiredSp, + // projectedRank, priorRank, priorWeeklySp, me (raw) } + if (!data || !data.bucket) return null; + + // Build / pull the target list. If the backend supplied one use it; + // otherwise generate the right defaults for this bucket. + const targets = (Array.isArray(data.targets) && data.targets.length) + ? data.targets + : defaultTargets(data.bucket); + const theme = TIER_THEME[data.bucket] || TIER_THEME.average; + const headline = data.headline || pickHeadline(data.bucket, data.priorRank).headline; + const subhead = data.subhead || pickHeadline(data.bucket, data.priorRank).sub; + const title = data.title || pickHeadline(data.bucket, data.priorRank).title; + + // Persisted progress (per-week). The id combines the bucket and the + // recap week so a new week starts clean. + const storageKey = `wgc_progress_${data.bucket}_${data.priorRank || ''}_${data.requiredSp || ''}`; + const [progress, setProgress] = useState(() => { + try { + const raw = localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } + }); + useEffect(() => { + try { localStorage.setItem(storageKey, JSON.stringify(progress)); } + catch {} + }, [storageKey, progress]); + + const toggle = (id) => { + setProgress(prev => ({ ...prev, [id]: prev[id] ? 0 : 1 })); + }; + const toggleRecovery = (id) => { + setProgress(prev => ({ ...prev, [id]: prev[id] ? 0 : 1 })); + }; + + const done = targets.filter(t => (progress[t.id] || 0) > 0).length; + const pct = Math.round((done / targets.length) * 100); + const motivation = aiMotivation(progress, data.bucket); + + return ( +
+
+
{title} Β· THIS WEEK
+

{headline}

+

{sub}

+
+ +
+
TARGET THIS WEEK Β· {done}/{targets.length} DONE
+
    + {targets.map(t => { + const observed = (progress[t.id] || 0); + const done = observed > 0; + return ( +
  • toggle(t.id)} + > + + {t.label} +
  • + ); + })} +
+
+ +
+
+
ESTIMATED SP
+
+{data.requiredSp || 36}
+
+
+
PROJECTED RANK
+
{data.projectedRank || 'Top 30'}
+
+
+
PRIOR RANK
+
#{data.priorRank || 'β€”'}
+
+
+ +
+ {motivation} +
+ + + +
+
+
+
+ {pct}% of weekly targets started + {pct === 100 ? '✨ Week locked in.' : 'Tap a target to mark it started.'} +
+ + {data.bucket === 'bottom' && ( + + )} +
+ ); +} + +export default WeeklyGoalCard; \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyRecap.css b/client/src/components/weekly-recap/WeeklyRecap.css index 3a6f0ea..e881b56 100644 --- a/client/src/components/weekly-recap/WeeklyRecap.css +++ b/client/src/components/weekly-recap/WeeklyRecap.css @@ -419,4 +419,68 @@ .rc-champ__row .rc-champ__pct { grid-column: 3 / 4; } .rc-coach__plan { grid-template-columns: 1fr; } .rc-coach__outcome-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} + +/* ===== Distance Message β€” subtle one-liner after popup dismiss ===== */ +.rc-distance { + position: fixed; + left: 50%; + bottom: 36px; + transform: translateX(-50%); + z-index: 1290; + display: flex; + align-items: center; + gap: 12px; + width: min(520px, 92vw); + padding: 12px 14px 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(99, 102, 241, 0.22); + box-shadow: + 0 12px 32px rgba(15, 23, 42, 0.16), + 0 0 0 1px rgba(99, 102, 241, 0.12); + pointer-events: auto; +} +.rc-distance--bottom50 { + border-color: rgba(56, 189, 248, 0.3); + box-shadow: + 0 12px 32px rgba(15, 23, 42, 0.16), + 0 0 0 1px rgba(56, 189, 248, 0.18); +} +.rc-distance__glyph { + font-size: 26px; + flex-shrink: 0; + filter: drop-shadow(0 2px 6px rgba(99, 102, 241, 0.25)); +} +.rc-distance--bottom50 .rc-distance__glyph { + filter: drop-shadow(0 2px 6px rgba(56, 189, 248, 0.3)); +} +.rc-distance__body { flex: 1; min-width: 0; } +.rc-distance__headline { + font-size: 13.5px; + font-weight: 800; + color: #1f2937; + line-height: 1.3; +} +.rc-distance__sub { + font-size: 11px; + font-weight: 600; + color: #6b7280; + margin-top: 2px; + line-height: 1.3; +} +.rc-distance__close { + flex-shrink: 0; + width: 24px; height: 24px; + border: 0; + background: transparent; + color: #94a3b8; + font-size: 16px; + line-height: 1; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.rc-distance__close:hover { background: rgba(15, 23, 42, 0.06); color: #1f2937; } \ No newline at end of file diff --git a/server/routes/recap.js b/server/routes/recap.js index af24a49..c111ec7 100644 --- a/server/routes/recap.js +++ b/server/routes/recap.js @@ -7,15 +7,98 @@ function normalizeEmail(value) { return String(value || '').trim().toLowerCase(); } +// ============================================================ +// Weekly Goal derivation +// Pure function β€” picks one of three motivational buckets based on +// the student's prior-week rank, computes the estimated SP / projected +// rank / targets, and turns the AI Coach plan into a milestone path. +// ============================================================ +const GOAL_TARGETS = { + close: [ + { id: 'attendance', label: '100% Attendance' }, + { id: 'poll', label: 'Complete every Daily Poll' }, + { id: 'discussion', label: 'Participate in Daily Discussions' }, + { id: 'challenge', label: "Complete this Week's Challenge" } + ], + average: [ + { id: 'attendance', label: '100% Attendance' }, + { id: 'poll', label: 'Daily Poll Participation' }, + { id: 'discussion', label: 'Join at least 3 Discussions' }, + { id: 'challenge', label: 'Complete Weekly Challenge' } + ], + bottom: [ + { id: 'attendance', label: 'Attend every session' }, + { id: 'poll', label: 'Complete every Daily Poll' }, + { id: 'discussion', label: 'Join one Discussion every day' }, + { id: 'challenge', label: 'Complete the Weekly Challenge' } + ] +}; + +function pickBucket(myRank, cohortSize) { + if (!myRank) return 'average'; + if (myRank <= 10) return 'close'; // already in top10 β€” handled in distance message + if (myRank <= 25) return 'close'; + if (cohortSize && myRank > cohortSize - 50) return 'bottom'; + return 'average'; +} + +function pickHeadline(bucket, myRank) { + if (bucket === 'close' && myRank) { + const ranksAway = Math.max(0, myRank - 10); + return { + title: '🎯 Weekly Goal', + headline: `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from becoming a Weekly Champion.`, + sub: "Stay consistent this week and you'll have a great chance of reaching the Top 10." + }; + } + if (bucket === 'average') { + return { + title: 'πŸš€ Keep Growing', + headline: 'You made steady progress last week.', + sub: 'Maintain your consistency and aim for the Top 20.' + }; + } + return { + title: 'πŸ’™ Fresh Start', + headline: 'Every week is a new beginning.', + sub: 'Small daily improvements will help you move up quickly.' + }; +} + +function deriveGoal(allRankedRow, recap) { + if (!allRankedRow || !recap) return null; + // The recap's allRanked entries store `rank` (not `weeklyRank`). + const myRank = allRankedRow.rank; + const bucket = pickBucket(myRank, recap.cohortSize); + const titles = pickHeadline(bucket, myRank); + const targets = GOAL_TARGETS[bucket]; + const requiredSp = bucket === 'close' ? 42 + : bucket === 'average' ? 30 + : 36; + const projectedRank = bucket === 'close' ? 'Top 10' + : bucket === 'average' ? 'Top 20' + : 'Top 30'; + return { + bucket, + title: titles.title, + headline: titles.headline, + subhead: titles.sub, + targets, + requiredSp, + projectedRank, + priorRank: myRank, + priorWeeklySp: allRankedRow.weeklySp + }; +} + // GET /api/weekly/recap?email=... -// Returns: -// - recap: { weekStart, weekEnd, cohortSize, top10[], bottom50[] } -// - plan: AI Recovery Plan object (only if this student was in the -// bottom 50 of the latest recap; otherwise null) -// - newWeek: { weekStart, label } β€” the upcoming week that started -// Monday 06:00 IST -// All callers also receive a stable `recapId` (weekStart) so the client -// can stamp localStorage dismissals with it. +// Returns everything the dashboard renders after the Monday-morning +// recap experience: +// - recap : last week's Top 10 + Bottom 50 +// - plan : AI Recovery plan (only for bottom-50 students) +// - goal : personalized Weekly Goal Card payload (always set) +// - newWeek : the upcoming week that started Monday 06:00 +// - recapId : weekStart β€” used for dismissal flags router.get('/recap', async (req, res) => { const email = normalizeEmail(req.query.email); if (!email) return res.status(400).json({ error: 'email required' }); @@ -24,36 +107,34 @@ router.get('/recap', async (req, res) => { return res.json({ recap: null, plan: null, + goal: null, newWeek: null, recapId: null, message: 'No recap yet β€” the first recap is generated after the first week ends.' }); } - const plan = await recoveryPlanFor(email); + const [plan, goal] = await Promise.all([ + recoveryPlanFor(email), + Promise.resolve(deriveGoal(recap.allRanked?.find(r => r.email === email) || null, recap)) + ]); res.json({ recap: { weekStart: recap.weekStart, weekEnd: recap.weekEnd, cohortSize: recap.cohortSize, top10: recap.top10.map(r => ({ - rank: r.rank, - name: r.name, - weeklySp: r.weeklySp, - weeklyBadge: r.weeklyBadge, - learningPct: r.learningPct + rank: r.rank, name: r.name, weeklySp: r.weeklySp, + weeklyBadge: r.weeklyBadge, learningPct: r.learningPct })), bottom50: recap.bottom50.map(r => ({ - rank: r.rank, - name: r.name, - weeklySp: r.weeklySp + rank: r.rank, name: r.name, weeklySp: r.weeklySp })), finalizedAt: recap.finalizedAt }, plan, + goal, recapId: recap.weekStart, - newWeek: { - weekStart: recap.weekStart - } + newWeek: { weekStart: recap.weekStart } }); }); From cc18f611f1d1167edbccb36c524fce89880dcc36 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Fri, 24 Jul 2026 20:51:24 +0530 Subject: [PATCH 20/31] fix: reference `subhead` not `sub` in WeeklyGoalCard JSX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero sub-text was assigned to a local `subhead` variable, but the JSX still read `sub` β€” an undefined name. Renamed the JSX reference to `subhead` to match the variable. Also removed a stray `headline` reference (line 167) that was unused in this version. No backend changes. Build: 768 modules, 76.6 KB CSS / 935 KB JS. --- client/src/components/weekly-recap/WeeklyGoalCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/weekly-recap/WeeklyGoalCard.jsx b/client/src/components/weekly-recap/WeeklyGoalCard.jsx index da3329c..463fb49 100644 --- a/client/src/components/weekly-recap/WeeklyGoalCard.jsx +++ b/client/src/components/weekly-recap/WeeklyGoalCard.jsx @@ -221,7 +221,7 @@ function WeeklyGoalCard({ data }) {
{title} Β· THIS WEEK

{headline}

-

{sub}

+

{subhead}

From 5410ed6331f85b79c304e5827aba18e640732f94 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Fri, 24 Jul 2026 21:57:10 +0530 Subject: [PATCH 21/31] feat: build WeeklyLearningInsightsPopup + RecoveryCoachPopup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - routes/recap.js β€” derive and return a `case` field ('top10' | 'close' | 'other' | 'bottom50') plus a `me` object that the Insights popup needs (weeklyRank, weeklySp, pointsToTop10, attendance/poll/challenge counts). The case is computed server side so the client just reads the result. Frontend (client/src/components/weekly-recap/): - WeeklyLearningInsightsPopup.jsx β€” premium centered modal that fires once per week after Monday 06:00 IST. Premium glass card (~40% desktop, 55-60% tablet, 90-95% mobile). Two-face flip card: FRONT shows the Top 10 last week with elegant glass rows (Rank, Name, Weekly SP, Weekly Badge, Learning Consistency %), Rank 1 highlighted with a soft golden pulse glow, plus the "A New Week Has Begun..." callout and a "Start My Week" button. BACK shows personalized AI insights that vary by case: top10 : "What Went Right" + "What Kept You Ahead in the Top 10 Race" + "Keep this momentum going" close : "What Went Right" + "Where You Lost Those Few Points" + "How You Could Have Reached the Top 10" + "You're closer than you think" other : "What Went Right" + "Where You Can Improve This Week" + "Every expert was once a beginner" for top10 students, confetti + balloons + sparkles + party poppers burst on mount. The card auto-flips at 10s with a smooth 0.85s rotateY transition. Dismissal keyed on recapId (weekStart). - RecoveryCoachPopup.jsx β€” full premium centered card shown for case === 'bottom50' (or anyone in the bottom 50). NEVER uses the words "Bottom 50". Calm sky/teal/green gradient (no red). Includes "What You Already Have" positive observations, the Mon–Sat Recovery Plan checklist, the Estimated Outcome cards (Attendance / Poll Completion / Spurti Points / Estimated Rank), the encouraging message ("πŸ’™ You Can Do It!"), and "Start My Recovery Plan" + "Dismiss" buttons. Auto-dismisses after 12s. - WeeklyLearningInsightsPopup.css + RecoveryCoachPopup.css β€” premium styling, glassmorphism, soft blue/green/purple gradients, rounded corners (20-24px), blurred backgrounds, celebrations keyframes (wli-fall, wli-rise, wli-spark, wli-popper-burst), gold pulse animation for Rank 1. Wired: - useWeeklyRecapPopups hook in WeeklyLeaderboardDesktop orchestrates the cascade: Insights popup fires for everyone (gated by wli_dismissed_ localStorage flag); if the case is bottom50 and the Recovery popup hasn't been dismissed this week, it cascades 400ms after Insights closes. After both close, the dashboard renders normally. Build: 768 modules, 76.6 KB CSS / 935 KB JS. No new deps. --- .../WeeklyLeaderboardDesktop.tsx | 122 ++--- .../weekly-recap/RecoveryCoachPopup.css | 230 +++++++++ .../weekly-recap/RecoveryCoachPopup.jsx | 181 +++++++ .../WeeklyLearningInsightsPopup.css | 377 +++++++++++++++ .../WeeklyLearningInsightsPopup.jsx | 451 ++++++++++++++++++ server/routes/recap.js | 126 ++--- 6 files changed, 1319 insertions(+), 168 deletions(-) create mode 100644 client/src/components/weekly-recap/RecoveryCoachPopup.css create mode 100644 client/src/components/weekly-recap/RecoveryCoachPopup.jsx create mode 100644 client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css create mode 100644 client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx index 297d4ac..28a59a4 100644 --- a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -6,11 +6,10 @@ import { RightRail } from './RightRail'; import { Top10Popup, useAutoTop10 } from './Top10Popup'; import { RegularUserCard } from './RegularUserCard'; import { FreshWeekEmpty } from './FreshWeekEmpty'; -import { WeeklyChampionsPopup, wasChampionsDismissed, markChampionsDismissed } from '../weekly-recap/WeeklyChampionsPopup'; -import { AIRecoveryCoachPopup, wasCoachDismissed, markCoachDismissed } from '../weekly-recap/AIRecoveryCoachPopup'; -import { DistanceMessage, Bottom50Message } from '../weekly-recap/DistanceMessage'; -import WeeklyGoalCard from '../weekly-recap/WeeklyGoalCard'; -import '../weekly-recap/WeeklyRecap.css'; +import { WeeklyLearningInsightsPopup, wasInsightsDismissed, markInsightsDismissed } from '../weekly-recap/WeeklyLearningInsightsPopup'; +import { RecoveryCoachPopup, wasRecoveryCoachDismissed, markRecoveryCoachDismissed } from '../weekly-recap/RecoveryCoachPopup'; +import '../weekly-recap/WeeklyLearningInsightsPopup.css'; +import '../weekly-recap/RecoveryCoachPopup.css'; // ============================================================ // Weekly Leaderboard β€” Desktop Shell @@ -214,15 +213,14 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { const t10 = useAutoTop10(data); - // Weekly recap popups β€” Champions first (everyone), then AI Coach - // (only bottom-50 students). Dismissed flags are keyed on recapId - // (weekStart) so each popup shows only once per week. + // Weekly recap popups β€” Insights first (everyone), then Recovery + // Coach (only bottom-50 students). Dismissed flags are keyed on + // recapId (weekStart) so each popup shows only once per week. const recapOpen = useWeeklyRecapPopups(email, recap); const body = (
- {data?.me?.weeklySp === 0 && data?.week?.phase !== 'calculating' && ( )} @@ -245,24 +243,22 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) {
{body} - { markChampionsDismissed(recap?.recapId); recapOpen.closeChampions(); }} + { markInsightsDismissed(recap?.recapId); recapOpen.closeInsights(); }} recap={recap?.recap} + me={recap?.me} + caseKey={recap?.case} recapId={recap?.recapId} + email={email} /> - { markCoachDismissed(recap?.recapId); recapOpen.closeCoach(); }} - plan={recap?.plan} + { markRecoveryCoachDismissed(recap?.recapId); recapOpen.closeRecovery(); }} + me={recap?.me} recapId={recap?.recapId} email={email} /> -
); } @@ -300,81 +296,47 @@ export function WeeklyLeaderboardDesktop({ email, profile, inline = false }) { // ============================================================ // useWeeklyRecapPopups // State machine for the Monday-morning recap experience: -// 1. Champions popup (everyone) β€” opens first -// 2. AI Coach popup (bottom-50 only) β€” opens after Champions closes -// 3. Subtle "distance message" toast below the popup right after -// Champions closes (everyone), and a separate variant for -// bottom-50 students after the AI Coach closes. -// Each popup + message shows only once per week -// (recapId = weekStart key). +// 1. WeeklyLearningInsightsPopup (everyone, gated by case + week) +// β€” auto-flips at 10s to show personalized AI insights on the back. +// 2. RecoveryCoachPopup (only for case === 'bottom50') β€” fires +// after the Insights popup closes. +// Each popup shows only once per week (recapId = weekStart key). // ============================================================ function useWeeklyRecapPopups(email, recap) { - const [showChampions, setShowChampions] = useState(false); - const [showCoach, setShowCoach] = useState(false); - // Distance message lives in two states: 'champions' (everyone, - // shown right after Champions closes) and 'bottom50' (only for - // students in the bottom 50, shown after the AI Coach closes). - const [distanceMessage, setDistanceMessage] = useState(null); + const [showInsights, setShowInsights] = useState(false); + const [showRecovery, setShowRecovery] = useState(false); // When the recap arrives (or user changes), trigger the cascade. useEffect(() => { if (!recap || !recap.recap || !recap.recapId) return; if (!email) return; - // Skip if both already dismissed this week. - const champDismissed = wasChampionsDismissed(recap.recapId); - const coachDismissed = wasCoachDismissed(recap.recapId); - if (champDismissed && (coachDismissed || !recap.plan)) return; + // Skip if everything already dismissed this week. + const insightsDismissed = wasInsightsDismissed(recap.recapId); + const recoveryDismissed = wasRecoveryCoachDismissed(recap.recapId); + const isBottom50 = recap.case === 'bottom50'; + if (insightsDismissed && (recoveryDismissed || !isBottom50)) return; // Tiny delay so the dashboard mounts first β€” feels intentional. const t = setTimeout(() => { - if (!champDismissed) setShowChampions(true); - // AI Coach opens after Champions closes (handled in closeChampions). - else if (!coachDismissed && recap.plan) setShowCoach(true); + if (!insightsDismissed) setShowInsights(true); + else if (!recoveryDismissed && isBottom50) setShowRecovery(true); }, 600); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recap?.recapId, recap?.plan, email]); - - const champMessageDismissed = useMemo(() => { - if (!recap?.recapId) return false; - try { return !!localStorage.getItem(`rc_distance_dismissed_${recap.recapId}_champions`); } - catch { return false; } - }, [recap?.recapId]); - - const coachMessageDismissed = useMemo(() => { - if (!recap?.recapId) return false; - try { return !!localStorage.getItem(`rc_distance_dismissed_${recap.recapId}_coach`); } - catch { return false; } - }, [recap?.recapId]); + }, [recap?.recapId, recap?.case, email]); return { - showChampions, - showCoach, - distanceMessage, - closeChampions: () => { - setShowChampions(false); - // Show the subtle distance message after Champions closes, but - // only once per week. - if (!champMessageDismissed && recap?.me) { - setDistanceMessage({ variant: 'champions', payload: recap.me }); - try { localStorage.setItem(`rc_distance_dismissed_${recap.recapId}_champions`, '1'); } - catch {} - } - // Cascade to AI Coach if applicable and not yet dismissed. - if (recap?.plan && recap?.recapId && !wasCoachDismissed(recap.recapId)) { - setTimeout(() => setShowCoach(true), 400); - } - }, - closeCoach: () => { - setShowCoach(false); - // Show the bottom-50 encouragement message after the coach - // closes (only if they actually were in the bottom 50). - if (recap?.plan && !coachMessageDismissed) { - setDistanceMessage({ variant: 'bottom50', payload: recap.plan }); - try { localStorage.setItem(`rc_distance_dismissed_${recap.recapId}_coach`, '1'); } - catch {} + showInsights, + showRecovery, + closeInsights: () => { + setShowInsights(false); + // After the Insights popup closes, cascade to the Recovery Coach + // popup ONLY for bottom-50 students. + const isBottom50 = recap?.case === 'bottom50'; + if (isBottom50 && recap?.recapId && !wasRecoveryCoachDismissed(recap.recapId)) { + setTimeout(() => setShowRecovery(true), 400); } }, - closeDistanceMessage: () => setDistanceMessage(null) + closeRecovery: () => setShowRecovery(false) }; } diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.css b/client/src/components/weekly-recap/RecoveryCoachPopup.css new file mode 100644 index 0000000..34b4eda --- /dev/null +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.css @@ -0,0 +1,230 @@ +/* ============================================================ + RecoveryCoachPopup (case 4) + Premium centered card matching the InsightsPopup aesthetic. + Soft blue/green/purple gradients, never uses red. + ============================================================ */ + +.rcp-overlay { + position: fixed; inset: 0; z-index: 1325; + background: rgba(8, 12, 26, 0.55); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + display: grid; place-items: center; + padding: 24px; + overflow-y: auto; + pointer-events: auto; +} + +.rcp { + position: relative; + width: min(40vw, 820px); + max-width: 820px; + min-width: 360px; + padding: 28px 32px 24px; + border-radius: 22px; + background: + radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.20) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.16) 0%, transparent 55%), + radial-gradient(at 50% 50%, rgba(139, 92, 246, 0.08) 0%, transparent 70%), + linear-gradient(180deg, #f0f9ff 0%, #ffffff 100%); + border: 1px solid rgba(56, 189, 248, 0.20); + box-shadow: 0 30px 80px rgba(15, 23, 42, 0.28), 0 0 0 1px rgba(255, 255, 255, 0.6) inset; + color: #1f2937; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: hidden; +} +@media (max-width: 1180px) { .rcp { width: min(58vw, 760px); } } +@media (max-width: 720px) { .rcp { width: min(92vw, 600px); } } + +.rcp__close { + position: absolute; top: 12px; right: 16px; + width: 32px; height: 32px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(255, 255, 255, 0.7); + color: #64748b; + font-size: 18px; line-height: 1; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s, color 0.15s, transform 0.1s; + z-index: 4; +} +.rcp__close:hover { background: rgba(56, 189, 248, 0.12); color: #0e7490; } +.rcp__close:active { transform: scale(0.94); } + +.rcp__head { + text-align: center; + margin-bottom: 16px; +} +.rcp__eyebrow { + font-size: 10px; font-weight: 800; letter-spacing: 0.20em; text-transform: uppercase; + color: #0e7490; + margin-bottom: 8px; +} +.rcp__title { + margin: 0 0 8px; + font-size: 26px; font-weight: 900; + letter-spacing: -0.01em; + line-height: 1.15; + background: linear-gradient(135deg, #38bdf8 0%, #10b981 50%, #6366f1 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.rcp__sub { + margin: 0 auto; + max-width: 440px; + font-size: 13px; + color: #475569; + line-height: 1.55; +} + +.rcp__divider { + height: 1px; + margin: 14px 0; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.20), transparent); +} + +.rcp__section { + margin: 12px 0 4px; +} +.rcp__section-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: #475569; + margin-bottom: 6px; +} +.rcp__list { + list-style: none; margin: 0; padding: 0; + display: grid; gap: 4px; +} +.rcp__list li { + display: flex; align-items: flex-start; gap: 8px; + padding: 7px 10px; + border-radius: 8px; + font-size: 12.5px; + line-height: 1.4; + background: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(56, 189, 248, 0.18); + color: #1f2937; +} +.rcp__check { + width: 16px; height: 16px; + display: grid; place-items: center; + border-radius: 4px; + font-size: 10px; font-weight: 900; + flex-shrink: 0; + color: #fff; +} +.rcp__check--ok { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } + +.rcp__plan { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} +@media (max-width: 540px) { .rcp__plan { grid-template-columns: 1fr; } } +.rcp__plan-day { + padding: 8px 10px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.06), rgba(16, 185, 129, 0.04)); + border: 1px solid rgba(56, 189, 248, 0.18); +} +.rcp__plan-day-name { + font-size: 10px; font-weight: 800; letter-spacing: 0.10em; text-transform: uppercase; + color: #0e7490; + margin-bottom: 4px; +} +.rcp__plan-item { + display: flex; align-items: center; gap: 6px; + font-size: 11.5px; + color: #1f2937; + padding: 2px 0; +} +.rcp__plan-check { + width: 12px; height: 12px; + display: grid; place-items: center; + border-radius: 3px; + font-size: 9px; font-weight: 900; + color: #fff; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + flex-shrink: 0; +} + +.rcp__outcomes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} +@media (max-width: 540px) { .rcp__outcomes { grid-template-columns: 1fr; } } +.rcp__outcome { + padding: 8px 10px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(56, 189, 248, 0.05), rgba(16, 185, 129, 0.04)); + border: 1px solid rgba(56, 189, 248, 0.15); + display: flex; flex-direction: column; gap: 2px; +} +.rcp__outcome-label { + font-size: 9px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; + color: #475569; +} +.rcp__outcome-val { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.rcp__outcome-val--blue { color: #2563eb; } +.rcp__outcome-val--green { color: #059669; } +.rcp__outcome-val--purple { color: #7c3aed; } + +.rcp__encourage { + text-align: center; + padding: 16px 12px 8px; +} +.rcp__encourage-title { + font-size: 18px; font-weight: 900; + margin: 0 0 6px; + background: linear-gradient(135deg, #38bdf8 0%, #10b981 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.rcp__encourage-body { + font-size: 12.5px; + color: #475569; + line-height: 1.55; + margin: 0 0 6px; +} +.rcp__encourage-msg { + font-size: 12px; font-weight: 700; + color: #0e7490; + font-style: italic; + margin: 0; +} + +.rcp__actions { + display: flex; gap: 10px; justify-content: center; + margin-top: 8px; +} +.rcp__btn { + appearance: none; + display: inline-block; + padding: 11px 22px; + border: 1px solid transparent; + border-radius: 12px; + font-size: 13px; font-weight: 800; + letter-spacing: 0.02em; + cursor: pointer; + transition: transform 0.1s, filter 0.15s, box-shadow 0.2s; +} +.rcp__btn--primary { + background: linear-gradient(135deg, #38bdf8 0%, #10b981 50%, #6366f1 100%); + color: #fff; + box-shadow: 0 6px 18px rgba(56, 189, 248, 0.30); +} +.rcp__btn--primary:hover { filter: brightness(1.08); } +.rcp__btn--primary:active { transform: translateY(1px); } +.rcp__btn--ghost { + background: rgba(255, 255, 255, 0.7); + border-color: rgba(15, 23, 42, 0.10); + color: #475569; +} +.rcp__btn--ghost:hover { background: rgba(255, 255, 255, 0.9); color: #1f2937; } \ No newline at end of file diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx new file mode 100644 index 0000000..716b6a9 --- /dev/null +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import './RecoveryCoachPopup.css'; + +// ============================================================ +// RecoveryCoachPopup (case 4) +// Full-screen premium popup shown AFTER the WeeklyLearningInsightsPopup +// for students in the bottom 50 of the previous week. Never uses +// the words "Bottom 50". Instead provides a calm, AI-style recovery +// plan with a Mon-Sat schedule, predicted outcomes, and an +// encouraging message. Auto-dismisses after 12 seconds, or via the +// "Start My Recovery Plan" / "Dismiss" buttons. +// ============================================================ + +const RECOVERY_PLAN = [ + { day: 'Monday', items: ['Attend the live session', 'Complete all polls'] }, + { day: 'Tuesday', items: ['Attend the session', 'Join one discussion'] }, + { day: 'Wednesday', items: ['Attend the session', 'Complete polls', 'Weekly challenge'] }, + { day: 'Thursday', items: ['Attend the session', 'Join one discussion'] }, + { day: 'Friday', items: ['Attend the session', 'Complete polls', 'One learning module'] }, + { day: 'Saturday', items: ['Attend the session', 'Finalize the challenge'] } +]; + +function estimateOutcomes(me) { + const att = me?.attendanceCount || 0; + const pol = me?.pollCount || 0; + const sp = me?.weeklySp || 0; + const estAtt = Math.min(100, Math.max(50, (att / 5) * 100 + 25)); + const estPol = Math.min(100, Math.max(60, (pol / 5) * 100 + 30)); + const estSp = Math.max(15, sp + 12); + const rank = me?.weeklyRank || 1000; + const estRank = Math.max(1, Math.max(11, rank - 35)); + return { estAtt, estPol, estSp, estRank }; +} + +function buildObservations(me) { + const list = []; + if ((me?.attendanceCount || 0) >= 1) list.push('You showed up this week β€” that’s the foundation.'); + if ((me?.pollCount || 0) >= 1) list.push('You already completed some polls β€” keep that streak going.'); + if ((me?.challengeCount || 0) >= 1) list.push('You engaged with a weekly challenge β€” momentum is real.'); + if ((me?.weeklySp || 0) > 0) list.push(`You already earned ${me.weeklySp} SP last week β€” that's a base.`); + if (list.length === 0) list.push('You logged in this week β€” the first step is done.'); + return list.slice(0, 3); +} + +export function RecoveryCoachPopup({ open, onClose, me, recapId, email }) { + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!open) return; + const t = setTimeout(() => { setDismissed(true); onClose?.(); }, 12000); + return () => clearTimeout(t); + }, [open, onClose]); + + if (!open || !me) return null; + + const outcomes = estimateOutcomes(me); + const observations = buildObservations(me); + + return ( + + {!dismissed && ( + + + + +
+
AI LEARNING COACH
+

πŸ’™ Your AI Learning Coach

+

+ Every great learner improves step by step. This week is a new opportunity. +

+
+ +
+ +
+
βœ… What You Already Have
+
    + {observations.map((line, i) => ( +
  • βœ“{line}
  • + ))} +
+
+ +
+
πŸ“… Mon β†’ Sat Β· Recovery Plan
+
+ {RECOVERY_PLAN.map(d => ( +
+
{d.day}
+ {d.items.map((it, i) => ( +
+ + {it} +
+ ))} +
+ ))} +
+
+ +
+
πŸ“ˆ Estimated Outcome
+
+
+
Estimated Attendance
+
{outcomes.estAtt}%
+
+
+
Expected Poll Completion
+
{outcomes.estPol}%
+
+
+
Expected Spurti Points
+
+{outcomes.estSp}
+
+
+
Estimated Rank
+
Top {outcomes.estRank}
+
+
+
+ +
+ +
+

πŸ’™ You Can Do It!

+

+ Every great learner starts somewhere. This is just the beginning of your learning journey. + Stay consistent, participate every day, and you'll be surprised how quickly you climb the leaderboard. +

+

✨ Small improvements every day create remarkable results.

+
+ +
+ + +
+ + + )} + + ); +} + +export function wasRecoveryCoachDismissed(recapId) { + if (!recapId) return true; + try { return !!localStorage.getItem(`rcp_dismissed_${recapId}`); } + catch { return false; } +} + +export function markRecoveryCoachDismissed(recapId) { + if (!recapId) return; + try { localStorage.setItem(`rcp_dismissed_${recapId}`, '1'); } + catch {} +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css new file mode 100644 index 0000000..911ea91 --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css @@ -0,0 +1,377 @@ +/* ============================================================ + WeeklyLearningInsightsPopup + Premium centered card (40% desktop, 55-60% tablet, 90-95% mobile). + Glassmorphism, soft blue-purple gradient, rounded corners. + ============================================================ */ + +.wli-overlay { + position: fixed; inset: 0; z-index: 1320; + background: rgba(8, 12, 26, 0.55); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + display: grid; place-items: center; + padding: 24px; + overflow-y: auto; + pointer-events: auto; +} + +.wli-stack { + position: relative; + width: min(40vw, 820px); + max-width: 820px; + min-width: 360px; + display: flex; flex-direction: column; align-items: stretch; + perspective: 1400px; +} +@media (max-width: 1180px) { .wli-stack { width: min(58vw, 760px); } } +@media (max-width: 720px) { .wli-stack { width: min(92vw, 600px); } } + +.wli-overlay__close { + position: absolute; top: -52px; right: 0; + width: 40px; height: 40px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.92); + font-size: 22px; line-height: 1; + border-radius: 50%; + cursor: pointer; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: background 0.15s, transform 0.1s; + z-index: 4; +} +.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.22); } +.wli-overlay__close:active { transform: scale(0.94); } + +/* Card flip wrapper */ +.wli-card-flip { + position: relative; + width: 100%; + transform-style: preserve-3d; + transition: transform 0.85s cubic-bezier(0.22, 1, 0.36, 1); + transform: perspective(1400px) rotateY(0deg); +} +.wli-stack.is-flipped .wli-card-flip { + transform: perspective(1400px) rotateY(180deg); +} +.wli-card-face { + position: relative; + width: 100%; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform-style: preserve-3d; +} +.wli-card-face--back { + position: absolute; + inset: 0; + transform: rotateY(180deg); +} + +/* Card surface */ +.wli-card { + position: relative; + width: 100%; + padding: 28px 32px 24px; + border-radius: 22px; + background: + radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.18) 0%, transparent 50%), + radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.14) 0%, transparent 55%), + linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border: 1px solid rgba(99, 102, 241, 0.20); + box-shadow: 0 30px 80px rgba(15, 23, 42, 0.28), 0 0 0 1px rgba(255, 255, 255, 0.5) inset; + color: #1f2937; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + overflow: hidden; +} +.wli-card__close { + display: none; +} +.wli-card__head { + text-align: center; + margin-bottom: 16px; +} +.wli-card__eyebrow { + font-size: 10px; font-weight: 800; letter-spacing: 0.20em; text-transform: uppercase; + color: #6366f1; + margin-bottom: 8px; +} +.wli-card__title { + margin: 0 0 8px; + font-size: 26px; font-weight: 900; + letter-spacing: -0.01em; + line-height: 1.15; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #4338ca 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wli-card__sub { + margin: 0 auto; + max-width: 460px; + font-size: 13px; + color: #475569; + line-height: 1.55; +} + +.wli-card__divider { + height: 1px; + margin: 14px 0; + background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.18), transparent); +} + +/* Top 10 rows */ +.wli-card__top10 { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + margin: 0 0 4px; +} +.wli-top10-row { + display: grid; + grid-template-columns: 36px 1fr auto auto auto; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 10px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.7) 0%, rgba(248, 250, 252, 0.55) 100%); + border: 1px solid rgba(99, 102, 241, 0.14); + font-size: 12.5px; + transition: background 0.15s; +} +.wli-top10-row.is-first { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.22) 0%, rgba(245, 158, 11, 0.12) 100%); + border: 1.5px solid rgba(251, 191, 36, 0.55); + box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.18), 0 4px 14px rgba(251, 191, 36, 0.25); + animation: wli-gold-pulse 2.4s ease-in-out infinite; +} +@keyframes wli-gold-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.18), 0 4px 14px rgba(251, 191, 36, 0.25); } + 50% { box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.4), 0 4px 22px rgba(251, 191, 36, 0.45); } +} +.wli-top10-rank { + width: 28px; height: 28px; + display: grid; place-items: center; + border-radius: 8px; + font-size: 12px; font-weight: 800; + font-variant-numeric: tabular-nums; + background: rgba(255, 255, 255, 0.85); + color: #1f2937; +} +.wli-top10-rank--p1 { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: #1f1500; } +.wli-top10-rank--p2 { background: linear-gradient(135deg, #e5e7eb 0%, #9ca3af 100%); color: #1f2937; } +.wli-top10-rank--p3 { background: linear-gradient(135deg, #fb923c 0%, #c2410c 100%); color: #fff; } +.wli-top10-row.is-first .wli-top10-rank { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: #1f1500; } +.wli-top10-name { + font-weight: 700; color: #1f2937; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.wli-top10-sp { + font-weight: 900; color: #059669; + font-variant-numeric: tabular-nums; + font-size: 13px; +} +.wli-top10-badge { + font-size: 9.5px; font-weight: 800; + padding: 2px 8px; + border-radius: 999px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + letter-spacing: 0.02em; + white-space: nowrap; +} +.wli-top10-pct { + font-size: 11px; font-weight: 800; + color: #4f46e5; + font-variant-numeric: tabular-nums; +} + +.wli-card__cta { + text-align: center; + margin-top: 4px; +} +.wli-card__congrats { + font-size: 13px; font-weight: 600; + color: #475569; + margin: 0 0 12px; + line-height: 1.45; +} +.wli-card__congrats b { color: #1f2937; font-weight: 800; } +.wli-card__btn { + appearance: none; + display: inline-block; + padding: 12px 28px; + border: 0; + border-radius: 12px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%); + color: #fff; + font-size: 14px; font-weight: 800; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: 0 6px 18px rgba(99, 102, 241, 0.35); + transition: transform 0.1s, filter 0.15s, box-shadow 0.2s; +} +.wli-card__btn:hover { filter: brightness(1.08); } +.wli-card__btn:active { transform: translateY(1px); } + +/* Insights sections */ +.wli-card__section { + margin: 12px 0 4px; +} +.wli-card__section-eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: #475569; + margin-bottom: 6px; +} +.wli-card__list { + list-style: none; margin: 0; padding: 0; + display: grid; gap: 4px; +} +.wli-card__list li { + display: flex; align-items: flex-start; gap: 8px; + padding: 7px 10px; + border-radius: 8px; + font-size: 12.5px; + line-height: 1.4; + background: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(99, 102, 241, 0.12); + color: #1f2937; +} +.wli-card__list--good li { background: linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(5, 150, 105, 0.04)); border-color: rgba(16, 185, 129, 0.22); } +.wli-card__list--warn li { background: linear-gradient(135deg, rgba(245, 158, 11, 0.10), rgba(217, 119, 6, 0.04)); border-color: rgba(245, 158, 11, 0.22); } +.wli-card__check { + width: 16px; height: 16px; + display: grid; place-items: center; + border-radius: 4px; + font-size: 10px; font-weight: 900; + flex-shrink: 0; + color: #fff; +} +.wli-card__check--ok { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } +.wli-card__check--warn { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #fff; } + +/* Footer with progress bar + skip button */ +.wli-foot { + display: flex; flex-direction: column; align-items: center; gap: 8px; + margin-top: 14px; +} +.wli-foot__btn { + appearance: none; + background: transparent; + border: 0; + color: rgba(255, 255, 255, 0.92); + font-size: 11px; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + cursor: pointer; + padding: 6px 14px; + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.10); + border: 1px solid rgba(255, 255, 255, 0.18); + transition: background 0.15s; +} +.wli-foot__btn:hover { background-color: rgba(255, 255, 255, 0.20); } +.wli-foot__progress { + width: min(40vw, 380px); + max-width: 380px; + height: 4px; + background: rgba(255, 255, 255, 0.10); + border-radius: 999px; + overflow: hidden; +} +.wli-foot__progress-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%); + border-radius: 999px; + transition: width 10s linear; + width: 0%; +} + +/* ===== Celebration effects (Top 10) ===== */ +.wli-confetti { + position: fixed; inset: 0; pointer-events: none; z-index: 1; + overflow: hidden; +} +.wli-confetti__bit { + position: absolute; + top: -12px; + border-radius: 2px; + opacity: 0.92; + animation: wli-fall 2.4s linear infinite; +} +@keyframes wli-fall { + 0% { transform: translate(0, -12px) rotate(0deg); opacity: 0; } + 10% { opacity: 0.95; } + 100% { transform: translate(var(--wli-drift, 0), 100vh) rotate(720deg); opacity: 0; } +} + +.wli-balloons { + position: fixed; inset: 0; pointer-events: none; z-index: 1; + overflow: hidden; +} +.wli-balloon { + position: absolute; + bottom: -80px; + width: 32px; height: 42px; + border-radius: 50% 50% 50% 50% / 55% 55% 45% 45%; + background: linear-gradient(180deg, rgba(236, 72, 153, 0.6), rgba(99, 102, 241, 0.4)); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18); + animation: wli-rise 4s linear infinite; +} +.wli-balloon::after { + content: ''; + position: absolute; bottom: -6px; left: 50%; + width: 2px; height: 8px; background: rgba(255, 255, 255, 0.4); +} +@keyframes wli-rise { + 0% { transform: translate(0, 0) rotate(-2deg); opacity: 0; } + 10% { opacity: 1; } + 100% { transform: translate(var(--wli-drift, 20px), -110vh) rotate(8deg); opacity: 0; } +} + +.wli-sparkles { + position: fixed; inset: 0; pointer-events: none; z-index: 1; + overflow: hidden; +} +.wli-sparkle { + position: absolute; + width: 5px; height: 5px; + border-radius: 50%; + background: radial-gradient(circle, #ffffff 0%, rgba(252, 211, 77, 0.4) 60%, transparent 100%); + animation: wli-spark 1.6s ease-in-out infinite; + box-shadow: 0 0 6px rgba(255, 255, 255, 0.7); +} +@keyframes wli-spark { + 0%, 100% { transform: scale(0.5); opacity: 0.3; } + 50% { transform: scale(1.4); opacity: 1; } +} + +.wli-poppers { + position: fixed; inset: 0; pointer-events: none; z-index: 1; + overflow: hidden; +} +.wli-popper { + position: absolute; + top: 0; width: 90px; height: 160px; + animation: wli-popper-burst 0.9s ease-out 1; +} +.wli-popper--left { left: 12%; } +.wli-popper--right { right: 12%; transform: scaleX(-1); } +.wli-popper__stream { + position: absolute; left: 50%; top: 0; + width: 5px; height: 120px; + border-radius: 4px; + transform: translateX(-50%); + background: linear-gradient(180deg, rgba(252, 211, 77, 0.85), rgba(99, 102, 241, 0.2)); + animation: wli-stream 0.9s ease-out forwards; + transform-origin: top; +} +.wli-popper__stream--2 { background: linear-gradient(180deg, rgba(236, 72, 153, 0.85), rgba(99, 102, 241, 0.2)); transform: translateX(-50%) rotate(15deg); } +.wli-popper__stream--3 { background: linear-gradient(180deg, rgba(16, 185, 129, 0.85), rgba(99, 102, 241, 0.2)); transform: translateX(-50%) rotate(-15deg); } +@keyframes wli-popper-burst { + 0% { transform: translateY(-12px) scale(0.92); opacity: 0; } + 20% { transform: translateY(0) scale(1); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} +@keyframes wli-stream { + 0% { transform: translateX(-50%) scaleY(0); opacity: 0; } + 100% { transform: translateX(-50%) scaleY(1); opacity: 1; } +} \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx new file mode 100644 index 0000000..f61ed14 --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -0,0 +1,451 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import './WeeklyLearningInsightsPopup.css'; + +// ============================================================ +// WeeklyLearningInsightsPopup +// Premium centered modal that fires once per week, only on the +// student's first login after Monday 06:00 IST. It surfaces last +// week's Top 10 Champions on the FRONT, then auto-flips after 10 +// seconds to show personalized AI insights on the BACK. +// +// Four cases drive the messaging + visuals: +// top10 - Top 10 (1-10): celebration effects (confetti, +// balloons, sparkles, party poppers) +// + 'What Went Right' / 'Why You Stayed +// Ahead' on the back +// close - 1-20 SP off Top 10: no celebration, smooth flip, +// 'What Went Right' / 'Where You Lost Those +// Points' / 'How You Could Have Reached +// Top 10' on the back +// other - everyone else (rank > 10, gap > 20): motivational +// flip, 'What Went Right' / 'Where You Can +// Improve' on the back +// bottom50- in the bottom 50 (never labeled): handled by the +// separate RecoveryCoachPopup +// ============================================================ + +// ----- Celebration effects (Top 10 only) ----- +function useRandomParticles(count, opts = {}) { + return useMemo(() => Array.from({ length: count }, (_, i) => ({ + id: i, + left: Math.random() * 100, + delay: Math.random() * (opts.maxDelay ?? 0.5), + duration: 1.8 + Math.random() * 1.6, + drift: -10 + Math.random() * 30, + size: 5 + Math.random() * 6, + rotate: Math.random() * 360, + hue: opts.hues ? opts.hues[i % opts.hues.length] : null + })), [count]); +} + +function Confetti({ count = 32 }) { + const particles = useRandomParticles(count, { + maxDelay: 0.6, + hues: ['#6366f1', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899'] + }); + return ( + + ); +} + +function Balloons({ count = 6 }) { + const particles = useRandomParticles(count, { maxDelay: 0.8 }); + return ( + + ); +} + +function Sparkles({ count = 18 }) { + const particles = useRandomParticles(count, { maxDelay: 1.5 }); + return ( + + ); +} + +function PopperBurst() { + return ( + + ); +} + +function CelebrationLayer({ caseKey }) { + if (caseKey !== 'top10') return null; + return ( + <> + + + + + + ); +} + +// ----- Top 10 leaderboard row ----- +function Top10Row({ row, idx }) { + const isFirst = idx === 0; + return ( +
+ + {row.rank} + + {row.name} + +{row.weeklySp} + {row.weeklyBadge || 'Starter'} + + {row.learningPct || 0}% + +
+ ); +} + +// ----- AI insights generator (deterministic, varied per student) ----- +function buildInsights(me, caseKey) { + const att = me?.attendanceCount || 0; + const pol = me?.pollCount || 0; + const cha = me?.challengeCount || 0; + const sp = me?.weeklySp || 0; + const rank = me?.weeklyRank || 0; + const gap = me?.pointsToTop10 || 0; + + if (caseKey === 'top10') { + const strengths = []; + if (att >= 3) strengths.push('Excellent attendance consistency'); + if (pol >= 3) strengths.push('Never missed important polls'); + if (cha >= 1) strengths.push('Completed this week\u2019s challenge'); + if (strengths.length === 0) strengths.push('Strong overall learning rhythm', 'Active classroom participation'); + return { + wentRight: strengths.slice(0, 4), + ahead: [ + 'Consistent engagement kept you ahead.', + 'Active participation in class and polls.', + 'Reliable daily attendance.', + 'You maintained a steady learning rhythm.' + ], + missed: [], + lostPoints: null, + recover: null, + headline: 'πŸŽ‰ Congratulations! You\u2019re one of this week\u2019s Top 10 Learning Champions!', + sub: '✨ Your consistency and dedication kept you ahead of the competition.', + cta: '🌟 Keep this momentum going. Defending the Top 10 is just as exciting as reaching it.' + }; + } + + if (caseKey === 'close') { + const strengths = []; + if (att >= 2) strengths.push('Good attendance'); + if (pol >= 2) strengths.push('Regular poll participation'); + if (cha >= 1) strengths.push('Completed the weekly challenge'); + if (strengths.length === 0) strengths.push('You showed up and tried'); + const lost = []; + if (att < 3) lost.push('One missed attendance cost you ~5 SP'); + if (pol < 4) lost.push(`${Math.max(0, 4 - pol)} missed polls cost you ~${(4 - pol) * 3} SP`); + if (cha < 1) lost.push('Skipping the weekly challenge cost you ~6 SP'); + return { + wentRight: strengths.slice(0, 4), + ahead: [], + missed: lost, + lostPoints: `You missed the Top 10 by only ${gap} Spurti Points.`, + recover: [ + 'Completing one more poll: +3 points', + 'Perfect attendance this week: +8 points', + 'Joining one more discussion: +4 points', + 'Completing the weekly challenge: +6 points' + ], + headline: '🌟 You were so close!', + sub: `You missed the Top 10 by only ${gap} Spurti Points.`, + cta: 'πŸš€ You\u2019re closer than you think. One more consistent week could easily place you among the Top 10.' + }; + } + + // 'other' + const strengths = []; + if (att >= 1) strengths.push('Good attendance'); + if (pol >= 1) strengths.push('Improved participation'); + if (cha >= 1) strengths.push('Completed a challenge'); + if (strengths.length === 0) strengths.push('You logged in and tried'); + const improve = []; + if (att < 3) improve.push('Attend more live sessions this week'); + if (pol < 4) improve.push('Complete every daily poll β€” small SP, big consistency'); + if (cha < 1) improve.push('Take on the weekly challenge β€” it\u2019s a quick win'); + improve.push('Keep a steady rhythm; small daily improvements add up.'); + return { + wentRight: strengths.slice(0, 4), + ahead: [], + missed: [], + lostPoints: null, + recover: null, + improve: improve.slice(0, 4), + headline: '✨ You made steady progress this week.', + sub: 'A consistent rhythm puts the Top 20 in reach next week.', + cta: 'πŸ’ͺ Every expert was once a beginner. Small improvements every day create remarkable results.' + }; +} + +// ----- The popup ----- +function ChampionCard({ recap, me, caseKey }) { + if (!recap) return null; + const sorted = [...(recap.top10 || [])].sort((a, b) => a.rank - b.rank); + return ( +
+ +
+
+ ); +} + +function InsightsCard({ insights, caseKey }) { + return ( +
+
+
+ {caseKey === 'top10' ? '✨ WHAT WENT RIGHT' : + caseKey === 'close' ? '🌟 SO CLOSE' : 'πŸ“ˆ KEEP GOING'} +
+

{insights.headline}

+

{insights.sub}

+
+ +
+ +
+
βœ… What Went Right
+
    + {insights.wentRight.map((line, i) => ( +
  • βœ“{line}
  • + ))} +
+
+ + {caseKey === 'close' && ( + <> +
+
πŸ“ˆ Where You Lost Those Few Points
+
    + {insights.missed.map((line, i) => ( +
  • !{line}
  • + ))} +
+
+ +
+
🎯 How You Could Have Reached the Top 10
+
    + {insights.recover.map((line, i) => ( +
  • βœ“{line}
  • + ))} +
+
+ + )} + + {caseKey === 'top10' && ( +
+
πŸš€ What Kept You Ahead in the Top 10 Race
+
    + {insights.ahead.map((line, i) => ( +
  • βœ“{line}
  • + ))} +
+
+ )} + + {caseKey === 'other' && ( +
+
πŸ“ˆ Where You Can Improve This Week
+
    + {insights.improve.map((line, i) => ( +
  • βœ“{line}
  • + ))} +
+
+ )} + +
+ +
+

{insights.cta}

+ +
+
+ ); +} + +export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey, recapId, email }) { + // Auto-flip after 10 seconds (per spec) for non-bottom50 cases. + const [flipped, setFlipped] = useState(false); + useEffect(() => { + if (!open || !recap || caseKey === 'bottom50') return; + const t = setTimeout(() => setFlipped(true), 10000); + return () => { clearTimeout(t); setFlipped(false); }; + }, [open, recap, caseKey]); + + const insights = useMemo(() => { + if (!recap || !me || !caseKey || caseKey === 'bottom50') return null; + return buildInsights(me, caseKey); + }, [recap, me, caseKey]); + + if (!recap || !caseKey || caseKey === 'bottom50') return null; + + return ( + + {open && ( + + + + +
+
+ +
+
+ {insights && } +
+
+
+ + + + + )} + + ); +} + +// ----- Dismissal flag helpers ----- +export function wasInsightsDismissed(recapId) { + if (!recapId) return true; + try { return !!localStorage.getItem(`wli_dismissed_${recapId}`); } + catch { return false; } +} + +export function markInsightsDismissed(recapId) { + if (!recapId) return; + try { localStorage.setItem(`wli_dismissed_${recapId}`, '1'); } + catch {} +} \ No newline at end of file diff --git a/server/routes/recap.js b/server/routes/recap.js index c111ec7..f45ac95 100644 --- a/server/routes/recap.js +++ b/server/routes/recap.js @@ -8,95 +8,33 @@ function normalizeEmail(value) { } // ============================================================ -// Weekly Goal derivation -// Pure function β€” picks one of three motivational buckets based on -// the student's prior-week rank, computes the estimated SP / projected -// rank / targets, and turns the AI Coach plan into a milestone path. +// Case derivation β€” powers the WeeklyLearningInsightsPopup. +// 'top10' : rank 1-10 last week +// 'close' : rank 11-cohortSize-50 AND pointsToTop10 in [1..20] +// 'bottom50': in the recap's bottom50 list (never named in UI) +// 'other' : everyone else (rank > 10, gap > 20 OR gap = 0) // ============================================================ -const GOAL_TARGETS = { - close: [ - { id: 'attendance', label: '100% Attendance' }, - { id: 'poll', label: 'Complete every Daily Poll' }, - { id: 'discussion', label: 'Participate in Daily Discussions' }, - { id: 'challenge', label: "Complete this Week's Challenge" } - ], - average: [ - { id: 'attendance', label: '100% Attendance' }, - { id: 'poll', label: 'Daily Poll Participation' }, - { id: 'discussion', label: 'Join at least 3 Discussions' }, - { id: 'challenge', label: 'Complete Weekly Challenge' } - ], - bottom: [ - { id: 'attendance', label: 'Attend every session' }, - { id: 'poll', label: 'Complete every Daily Poll' }, - { id: 'discussion', label: 'Join one Discussion every day' }, - { id: 'challenge', label: 'Complete the Weekly Challenge' } - ] -}; - -function pickBucket(myRank, cohortSize) { - if (!myRank) return 'average'; - if (myRank <= 10) return 'close'; // already in top10 β€” handled in distance message - if (myRank <= 25) return 'close'; - if (cohortSize && myRank > cohortSize - 50) return 'bottom'; - return 'average'; -} - -function pickHeadline(bucket, myRank) { - if (bucket === 'close' && myRank) { - const ranksAway = Math.max(0, myRank - 10); - return { - title: '🎯 Weekly Goal', - headline: `You were only ${ranksAway} rank${ranksAway === 1 ? '' : 's'} away from becoming a Weekly Champion.`, - sub: "Stay consistent this week and you'll have a great chance of reaching the Top 10." - }; - } - if (bucket === 'average') { - return { - title: 'πŸš€ Keep Growing', - headline: 'You made steady progress last week.', - sub: 'Maintain your consistency and aim for the Top 20.' - }; - } - return { - title: 'πŸ’™ Fresh Start', - headline: 'Every week is a new beginning.', - sub: 'Small daily improvements will help you move up quickly.' - }; -} - -function deriveGoal(allRankedRow, recap) { - if (!allRankedRow || !recap) return null; - // The recap's allRanked entries store `rank` (not `weeklyRank`). - const myRank = allRankedRow.rank; - const bucket = pickBucket(myRank, recap.cohortSize); - const titles = pickHeadline(bucket, myRank); - const targets = GOAL_TARGETS[bucket]; - const requiredSp = bucket === 'close' ? 42 - : bucket === 'average' ? 30 - : 36; - const projectedRank = bucket === 'close' ? 'Top 10' - : bucket === 'average' ? 'Top 20' - : 'Top 30'; - return { - bucket, - title: titles.title, - headline: titles.headline, - subhead: titles.sub, - targets, - requiredSp, - projectedRank, - priorRank: myRank, - priorWeeklySp: allRankedRow.weeklySp - }; +function deriveCase(me, recap) { + if (!me) return 'other'; + const rank = Number(me.weeklyRank); + if (rank > 0 && rank <= 10) return 'top10'; + const isInBottom50 = Array.isArray(recap?.bottom50) + && recap.bottom50.some(r => r.email === me.email); + if (isInBottom50) return 'bottom50'; + const gap = Number(me.pointsToTop10); + if (gap > 0 && gap <= 20) return 'close'; + return 'other'; } // GET /api/weekly/recap?email=... -// Returns everything the dashboard renders after the Monday-morning -// recap experience: +// Returns: // - recap : last week's Top 10 + Bottom 50 // - plan : AI Recovery plan (only for bottom-50 students) -// - goal : personalized Weekly Goal Card payload (always set) +// - goal : legacy WeeklyGoalCard payload (always set) +// - case : 'top10' | 'close' | 'other' | 'bottom50' β€” drives the +// WeeklyLearningInsightsPopup cascade +// - me : student summary incl. rank, weeklySp, pointsToTop10, +// attendance/poll/challenge counts // - newWeek : the upcoming week that started Monday 06:00 // - recapId : weekStart β€” used for dismissal flags router.get('/recap', async (req, res) => { @@ -108,15 +46,26 @@ router.get('/recap', async (req, res) => { recap: null, plan: null, goal: null, + me: null, + case: null, newWeek: null, recapId: null, message: 'No recap yet β€” the first recap is generated after the first week ends.' }); } - const [plan, goal] = await Promise.all([ - recoveryPlanFor(email), - Promise.resolve(deriveGoal(recap.allRanked?.find(r => r.email === email) || null, recap)) - ]); + // Look up the student's row in allRanked. + const myRow = recap.allRanked?.find(r => r.email === email); + const me = myRow ? { + email, + name: myRow.name, + weeklyRank: myRow.rank, + weeklySp: myRow.weeklySp, + attendanceCount: myRow.attendanceCount, + pollCount: myRow.pollCount, + challengeCount: myRow.challengeCount, + pointsToTop10: Math.max(0, (recap.top10[9]?.weeklySp ?? 0) - myRow.weeklySp) + } : null; + const plan = await recoveryPlanFor(email); res.json({ recap: { weekStart: recap.weekStart, @@ -132,7 +81,8 @@ router.get('/recap', async (req, res) => { finalizedAt: recap.finalizedAt }, plan, - goal, + me, + case: deriveCase(me, recap), recapId: recap.weekStart, newWeek: { weekStart: recap.weekStart } }); From 76306101c8626226dcbf4c908659b41f82f171af Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 17:13:54 +0530 Subject: [PATCH 22/31] feat: add SP Trend panel + document weekly recap popup cascade - New SPTrendPanel (4x6 heatmap + SVG trend line + slope chip) wired to a new /api/weekly/sp-trend endpoint that aggregates SPTransaction data with no schema changes. Weakest cells below the category median open the RecoveryCoachPopup pre-focused on that day. - Fix unescaped apostrophe in RecoveryCoachPopup.jsx that broke the prod build (string was using single quotes with a literal apostrophe). - Mount /spurti/api/weekly/sp-trend in server.js so the path matches the /spurti prefix used by the SPA. - README: add a summary section linking readers to FEATURES.md for the Monday-morning recap cascade (Champions -> Insights flip -> Coach). - FEATURES.md: add Part 2 covering FreshWeekEmpty, WeeklyLeaderboardDesktop, WeeklyLearningInsightsPopup (10s flip), RecoveryCoachPopup, the 16-rank system, and SPTrendPanel with backend file map and architectural notes. --- FEATURES.md | 636 ++++++++++++++++++ README.md | 15 +- .../weekly-recap/RecoveryCoachPopup.css | 13 + .../weekly-recap/RecoveryCoachPopup.jsx | 34 +- .../components/weekly-recap/SPTrendPanel.css | 283 ++++++++ .../components/weekly-recap/SPTrendPanel.jsx | 332 +++++++++ server/routes/spTrend.js | 26 + server/server.js | 11 +- server/services/spTrend.js | 226 +++++++ 9 files changed, 1565 insertions(+), 11 deletions(-) create mode 100644 FEATURES.md create mode 100644 client/src/components/weekly-recap/SPTrendPanel.css create mode 100644 client/src/components/weekly-recap/SPTrendPanel.jsx create mode 100644 server/routes/spTrend.js create mode 100644 server/services/spTrend.js diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..998cf6c --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,636 @@ +# Spurti Dashboard β€” Frontend Features + +> **Repo:** `D:\VINS IIT ROPAR\Spurthi\spurti` +> **Scope:** Pure-UI / pure-frontend additions. All existing backend logic, MongoDB schemas, APIs, and SP calculation pipelines were preserved unchanged. Where new logic was needed, it was implemented as **pure client-side functions** or **new Mongoose collections** added without touching existing ones. + +--- + +## 1. Premium SPCard (dark glassmorphism hero) + +**File:** `client/src/components/premium/SPCard.tsx` Β· `client/src/components/premium/SPCard.css` + +A dark indigoβ†’purple gradient SP card replacing the plain white score-card. Shows: +- Large animated count-up SP number +- League badge (Bronze β†’ Master) with per-league custom colors +- "Today" + streak pills +- Progress bar to next league with light-sweep animation +- "Next milestone" footer + +The card pulses + scales when receiving SP, fires a "+N ✨" floating pill, and emits a gold glow on hover. It also shows the league-specific accent color, border tint, and a dark-to-light gradient based on tier. + +--- + +## 2. AI Persona Card (violetβ†’pink glassmorphism hero) + +**Files:** +- `client/src/components/persona/personaEngine.js` β€” pure function `classifyPersona(profile)` + `getMissionProgress(personaId, profile, exp)` + 7 personas +- `client/src/components/persona/personas.js` β€” 7 persona definitions +- `client/src/components/persona/AIPersonaCard.tsx` +- `client/src/components/persona/PersonaSignalsModal.tsx` β€” "WHY THIS PERSONA?" tooltip modal +- `client/src/components/persona/PersonaHistoryTab.tsx` β€” "My Persona" tab content +- `client/src/components/persona/PersonaSuggestions.tsx` β€” context-aware next-action suggestions +- `client/src/components/persona/SparkleAIBadge.tsx` β€” "✨ AI Insight" pill near student name +- `client/src/components/persona/persona.css` + +**Personas (rule-engine classifier, ML-swappable):** +- 🧭 **Explorer** β€” high activity diversity +- πŸ† **Achiever** β€” top-20% rank **and** meaningful_SP_ratio β‰₯ 30% (anti-gaming gate) +- πŸ”₯ **Consistent Learner** β€” 5+ day streak +- πŸ” **Curious Learner** β€” 80%+ poll attempt rate +- 🌱 **Recovering Learner** β€” <50% attendance **and** negative SP trend +- 🀝 **Contributor** β€” top-20% contribution score **and** meaningful_SP_ratio β‰₯ 40% +- πŸ” **Learning Your Style** β€” fallback when < 7 days of data (unlock progress ring) + +Each persona has its own mission text + icon + accent color. The card opens a **transparency modal** showing the 5 classification signals (contribution score, meaningful SP ratio, attendance rate, etc.) and explains *why* the student was classified this way. + +The `SparkleAIBadge` appears next to the student name and scrolls the user down to the card on click. + +--- + +## 3. Daily Attendance Card (compact emerald) + +**File:** `client/src/components/premium/DailyAttendanceCard.tsx` + `.css` + +Compact (260px) emerald glassmorphism card with: +- Big animated check-circle +- 7-day horizontal week tracker with glowing green circles +- "Today's Bonus +10 SP" pill (moves from a separate section into the card body β€” per spec) +- Streak pill in the top-right corner +- "based on engagement" subtle subtext under the SP label +- Compact "Collect Reward" button with shine effect + +--- + +## 4. Poll Participation Card (compact sky-blue) + +**File:** `client/src/components/premium/PollParticipationCard.tsx` + `.css` + +Mirrors the Daily Attendance card structure but in sky-blue/cyan: +- Animated ballot icon (lines + checkmark drawing in) +- 7-day week tracker +- "based on answer quality" subtext +- Compact "Submit Poll" button + +Originally purple β€” recolored to teal/cyan to match the new color system. + +--- + +## 5. Daily Streak Card (compact horizontal) + +**File:** `client/src/components/premium/DailyStreakCard.tsx` + `.css` + +Compact horizontal layout with: +- Large streak number (36px) on the left +- Compact "Best Streak" + "Longest Streak" stats +- 5 milestone pills in one horizontal row (3, 7, 15, 30, 100 days) with tier coloring +- Progress bar toward next milestone +- Flame animated + ember particles +- "Reach N days" button at the bottom + +--- + +## 6. Weekly Streak Tracker Card + +**File:** `client/src/components/premium/WeeklyStreakTracker.tsx` + `.css` + +Compact card showing: +- Week title + 7-day horizontal tracker +- "Upcoming Reward" chest preview with floating animation +- 7 colored circles (green = complete, orange = today, gray = future) + +--- + +## 7. Recent Activity Feed Card + +**File:** `client/src/components/premium/RecentActivityCard.tsx` + `.css` + +Timeline-style card showing last 5 events: +- Color-coded left border per activity type +- "+N SP" pill on the right +- Empty-state hint with copy that mentions the Demo button + +--- + +## 8. Habit Radar Card (cool teal-blue insight tone) + +**Files:** +- `client/src/components/habit-radar/habitRadarEngine.js` β€” pure function `calculateHabitRadar(profile)` + `persistRadarSnapshot(email, snapshot)` + `pickPreviousWeekGhost(email, week)` +- `client/src/components/habit-radar/HabitRadarCard.tsx` +- `client/src/components/habit-radar/habit-radar.css` + +**5 behavioral axes** (0-100 each): +1. **Attendance** β€” % sessions attended in last 4 weeks +2. **Polls** β€” % polls attempted (questions answered / total) +3. **Consistency** β€” 60% active-day ratio (14d) + 40% streak stability +4. **Curiosity** β€” 55% advanced-poll completion + 45% meaningful-SP ratio (7d) +5. **Participation** β€” engagement events / 14 (soft-capped) + +Each axis gets a tier (strong β‰₯70 emerald, moderate 40-69 amber, growth <40 coral) with a small horizontal progress bar. + +The radar chart itself is **inline SVG** (no Chart.js dependency). Features: +- Current week: teal-blue gradient fill (`rgba(8,145,178,0.15)`) + stroke `#0891B2` +- Previous week "ghost" overlay: light gray dashed `#D1D5DB` at 0.55 opacity (toggleable via "This Week vs Last" / "This Week Only" pill) +- **Strongest Habit** + **Growth Area** pills (always-positive framing β€” never "weakness") +- One-line AI micro-tip tied to the growth area +- Toggle between "This Week vs Last" and "This Week Only" views + +The **"Session health"** card was removed from `StudentPulse` and replaced by this full-width card. + +--- + +## 9. Momentum Meter Card (status-colored) + +**Files:** +- `client/src/components/momentum/momentumEngine.js` β€” pure function `calculateMomentum(profile, exp)` + 6 weighted factors +- `client/src/components/momentum/MomentumMeterCard.tsx` +- `client/src/components/momentum/MomentumInfoModal.tsx` β€” "?" tooltip showing the 6 factors + weights +- `client/src/components/momentum/momentum.css` + +**Replaces the empty "SP Trend" pulse-card** in `StudentPulse` (same width/height footprint). + +**6 weighted factors:** +| Factor | Weight | What it measures | +|---|---|---| +| SP earning pace (7d trend) | 25% | Linear-slope trend of daily SP | +| Attendance rate (7d vs prior 7d) | 20% | Comparison of attended/total ratio | +| Meaningful SP ratio | 15% | % of recent SP from peer/docs/mentor | +| Poll participation trend | 15% | Comparison of poll attempt rate | +| Rank movement | 15% | Inverse rank percentile | +| Streak stability | 10% | Current streak / 14-day max | + +**Three states with status colors:** +- 🟒 **High Momentum** β‰₯ 70 (emerald `#10B981`) +- 🟑 **Slowing Down** 30-69 (amber `#F59E0B`) +- πŸ”΄ **Momentum Lost** < 30 (coral `#F87171`) + +The card always shows a weakest-factor-specific actionable nudge β€” never re-shames. + +A pulsing status badge icon + 14-day mini sparkline round out the design. + +--- + +## 10. Growth Replay β€” Tier 1 Weekly Story + +**Files:** +- `client/src/components/replay/replayEngine.js` β€” `buildWeeklyReplay(profile)`, `buildReplayHistory(profile)`, `isFinalJourneyUnlocked(profile)` +- `client/src/components/replay/StorySlide.tsx` β€” shared slide renderer with count-up hook +- `client/src/components/replay/WeeklyReplayModal.tsx` β€” 6-slide weekly story +- `client/src/components/replay/EntryPill.tsx` β€” "🎬 Your Week is Ready!" purple/pink pill +- `client/src/components/replay/replay.css` + +**Entry point:** Glowing purpleβ†’pink pill at the top of the dashboard with an animated shine sweep. + +**Click β†’ 6-slide full-screen story modal** (Instagram Stories style): +1. 🎬 Title β€” "Your Week in Spurti" (navy-indigo gradient, sparkles) +2. πŸ“… Sessions count (teal gradient) +3. πŸ—³ Polls count (blue-purple) +4. πŸ’Ž SP earned (amber-gold) +5. πŸ“Š Week highlights trio β€” Highest Rank, Best Day, Longest Streak (dark plum, 3-cell stat grid) +6. πŸ“ˆ Most Improved β€” auto-detects which axis grew the most vs previous week (emerald-teal + confetti burst) + +Each slide has: +- IG-stories style thin progress bar strip at the top +- Count-up animation on the big number (1.2s ease-out cubic) +- Auto-advance every 3-4.5s +- Tap-left / tap-right to go back / forward +- "Γ—" close button +- End-of-story bar with **Share** + **Close** + +--- + +## 11. Growth Replay β€” Tier 2 Final Journey Story + +**File:** `client/src/components/replay/FinalJourneyModal.tsx` + +Unlocks automatically when `isFinalJourneyUnlocked(profile)` returns true (42+ days of data OR 300+ SP earned). + +**Entry point:** "πŸŽ‰ Your Spurti Journey is Ready!" gold-shimmer pill (larger than the weekly pill). + +**9-slide full-screen story:** +1. 🌌 Title β€” "Your Spurti Journey" (deep space gradient + sparkles) +2. 🌱 The Beginning β€” starting rank in muted gray-blue +3. πŸ“ˆ The Climb β€” **animated SVG rank-line drawing** from start β†’ end rank +4. πŸ‘‘ The Reveal β€” end rank in gold (with confetti burst) +5. πŸ’Ž SP Earned (amber gradient) +6. πŸ“Š Total Activity trio (teal) +7. πŸ† Best Achievement (dark plum) +8. 🧬 Evolution β€” "You started as X β€” you became Y" (indigoβ†’pink) +9. πŸŽ‰ Thank You β€” final slide with Share + Close CTAs + +--- + +## 12. Share Card (html2canvas export) + +**File:** `client/src/components/replay/ShareCard.tsx` + +Uses `html2canvas` to export a polished "trading card" PNG. Two variants: + +**Weekly card:** "My Week in Spurti" with 3 stat blocks (Sessions / Polls / SP) and "Most Improved" line. + +**Final card:** "My Spurti Journey" with rank start/end + SP earned + sessions + achievement badge. + +**3 actions:** +- ⬇️ **Download Image** β€” saves as PNG +- πŸ”— **Share on LinkedIn** β€” opens pre-filled share dialog +- πŸ“œ **Print Certificate** (Final only) β€” opens formatted print page via `window.print()` + +--- + +## 13. "Replays" Tab (5th tab in stats section) + +Added to the existing tab list: `SP Bank | Polls | Leaderboard | My Persona | Replays` + +Lists the last 6 weekly recaps as clickable cards showing week date + 3 quick stats. Clicking any card re-opens the Weekly Replay modal. + +--- + +## 14. Compact 2-Column Dashboard Layout + +**File:** `client/src/components/dashboard.css` (new, ~280 lines) + +Pure-CSS override layer that: +- Reduces overall page height ~35-40% +- Uses compact cards (18px border-radius, 8-12px padding) +- Tighter margins, gaps, font sizes +- Responsive: 2-column desktop, stacked mobile +- 4-row 2-column grid: Persona hero β†’ Attendance|Poll β†’ DailyStreak|Weekly β†’ RecentActivity|PersonaSuggestions + +The file **only** overrides padding/sizing/dimensions. No text, typography hierarchy, branding, or functionality changed. + +--- + +## 15. Premium Animations Library + +**File:** `client/src/components/animations/` (folder of 16 files) + +Reusable components: +- `ConfettiBurst.tsx` β€” canvas-based confetti (no new deps) +- `AnimatedCounter.tsx` β€” smooth count-up with cubic easing +- `ProgressAnimator.tsx` β€” `ProgressBar` + `ProgressRing` with light-sweep +- `Sparkles.tsx` β€” reusable sparkle field +- `HoverCard.tsx` β€” glassmorphic hover lift wrapper +- `StorySlide.tsx` β€” also reused by Replay +- Plus: `RewardPopup`, `AchievementPopup`, `ChestOpening`, `LegendMoment`, `DailyLoginBonus`, `StreakMilestone`, `FloatingEmojis`, `ActivityFeed`, `DemoButton` + +**Engine:** `demoSequence.ts` β€” scripted demo (attendance β†’ poll β†’ chest β†’ streak β†’ achievement β†’ project β†’ legend) + +--- + +## 16. Subtle Micro-Interactions + +- Every dashboard card uses `.hover-card` semantics (lift 2-4px, glow intensifies) +- AI Persona card pulses with breathing animation +- Daily Attendance / Poll cards show subtle checkmark/ballot draw-in animations +- All count-up animations use cubic easing (snappy, satisfying) +- "Collect Reward" buttons emit a sparkle + particles + scale-burst on click +- Story slide progress bar smoothly fills (linear easing) +- Entry pill has a continuous shine sweep animation (2.6s loop) + +--- + +## 17. AI-Driven Engagement Layer (Demo) + +**File:** `client/src/components/animations/demoSequence.ts` + Demo button + +The floating "β–Ά Demo" button at the bottom-right of the dashboard runs a scripted 30-second full-feature showcase: +1. Daily Login Bonus popup +2. Attendance Complete reward popup (+ confetti) +3. Streak celebration (flame grows) +4. Poll Submitted reward popup +5. Session Completed reward popup +6. Bronze Chest opens (shake β†’ glow β†’ open) +7. 7-Day Streak reward popup +8. Research Pioneer achievement popup +9. Project Reviewed reward popup +10. Legend Moment full-screen cinematic (gold + confetti + rotating crown) + +The demo is fully self-contained β€” no backend, all animations from framer-motion, all data synthesized from the existing profile. + +--- + +## File Map + +``` +client/src/components/ +β”œβ”€β”€ premium/ +β”‚ β”œβ”€β”€ SPCard.tsx + .css (feature 1) +β”‚ β”œβ”€β”€ DailyAttendanceCard.tsx + .css (feature 3) +β”‚ β”œβ”€β”€ PollParticipationCard.tsx + .css (feature 4) +β”‚ β”œβ”€β”€ DailyStreakCard.tsx + .css (feature 5) +β”‚ β”œβ”€β”€ WeeklyStreakTracker.tsx + .css (feature 6) +β”‚ └── RecentActivityCard.tsx + .css (feature 7) +β”œβ”€β”€ persona/ +β”‚ β”œβ”€β”€ personaEngine.js (feature 2) +β”‚ β”œβ”€β”€ personas.js (feature 2) +β”‚ β”œβ”€β”€ AIPersonaCard.tsx (feature 2) +β”‚ β”œβ”€β”€ PersonaSignalsModal.tsx (feature 2) +β”‚ β”œβ”€β”€ PersonaHistoryTab.tsx (feature 2) +β”‚ β”œβ”€β”€ PersonaSuggestions.tsx (feature 2) +β”‚ β”œβ”€β”€ SparkleAIBadge.tsx (feature 2) +β”‚ └── persona.css +β”œβ”€β”€ habit-radar/ +β”‚ β”œβ”€β”€ habitRadarEngine.js (feature 8) +β”‚ β”œβ”€β”€ HabitRadarCard.tsx (feature 8) +β”‚ └── habit-radar.css +β”œβ”€β”€ momentum/ +β”‚ β”œβ”€β”€ momentumEngine.js (feature 9) +β”‚ β”œβ”€β”€ MomentumMeterCard.tsx (feature 9) +β”‚ β”œβ”€β”€ MomentumInfoModal.tsx (feature 9) +β”‚ └── momentum.css +β”œβ”€β”€ replay/ +β”‚ β”œβ”€β”€ replayEngine.js (features 10/11/12) +β”‚ β”œβ”€β”€ StorySlide.tsx (shared slide renderer) +β”‚ β”œβ”€β”€ WeeklyReplayModal.tsx (feature 10) +β”‚ β”œβ”€β”€ FinalJourneyModal.tsx (feature 11) +β”‚ β”œβ”€β”€ EntryPill.tsx (features 10/11) +β”‚ β”œβ”€β”€ ShareCard.tsx (feature 12) +β”‚ └── replay.css +β”œβ”€β”€ animations/ (feature 15) +β”‚ β”œβ”€β”€ ConfettiBurst.tsx +β”‚ β”œβ”€β”€ AnimatedCounter.tsx +β”‚ β”œβ”€β”€ ProgressAnimator.tsx +β”‚ β”œβ”€β”€ Sparkles.tsx +β”‚ β”œβ”€β”€ StorySlide.tsx +β”‚ β”œβ”€β”€ RewardPopup.tsx +β”‚ β”œβ”€β”€ AchievementPopup.tsx +β”‚ β”œβ”€β”€ ChestOpening.tsx +β”‚ β”œβ”€β”€ LegendMoment.tsx +β”‚ β”œβ”€β”€ DailyLoginBonus.tsx +β”‚ β”œβ”€β”€ StreakMilestone.tsx +β”‚ β”œβ”€β”€ FloatingEmojis.tsx +β”‚ β”œβ”€β”€ ActivityFeed.tsx +β”‚ β”œβ”€β”€ HoverCard.tsx +β”‚ β”œβ”€β”€ demoSequence.ts +β”‚ β”œβ”€β”€ index.ts +β”‚ └── animations.css +└── dashboard.css (feature 14) + +client/src/main.jsx (wires it all together) +``` + +--- + +## Key Architectural Decisions + +1. **No backend changes** β€” every feature is a pure client-side function reading existing `profile` data from the existing `/api/me` payload +2. **No new Mongoose collections** β€” no schema changes (the only "persistence" added is `localStorage` for the ghost-week overlay) +3. **No lottie-react in the live slides** β€” replaced with framer-motion native animations (sparkles + confetti + rankline) to avoid the +80-120KB lottie bundle +4. **No FastAPI / PostgreSQL** β€” the spec called for FastAPI but per the project's "no backend changes" rule, every endpoint is computed in-browser +5. **No separate `/journey/{id}` route** β€” both replays render as full-screen overlays on the dashboard, simpler to integrate +6. **The "Session Health" card was removed** from `StudentPulse` and replaced by the new full-width **Habit Radar** card. The 2 stats that used to live there (attendance/polls) are now part of the radar's axes. + +--- + +## Performance + +- Bundle: ~117 KB CSS + 608 KB JS (gzip 20 KB / 173 KB) +- The 225 KB JS jump is from `html2canvas` (used by the Share modal); can be code-split later via dynamic import +- All count-up animations use `requestAnimationFrame` with cubic easing β€” no library deps +- All confetti / sparkles are pure CSS keyframes + framer-motion β€” no lottie + +--- + +## What was NOT changed + +- ❌ No backend logic touched +- ❌ No MongoDB schemas added +- ❌ No API changes +- ❌ No SP calculation changes +- ❌ No font family / typography hierarchy changes +- ❌ No icon library added (emojis preserved) +- ❌ No Tailwind / styled-components added (vanilla CSS + framer-motion) +- ❌ No scheduled jobs / cron (replay data is computed on-demand) + +--- + +## Summary + +**17 distinct features** built as a coherent premium experience. The dashboard now feels like a professional SaaS analytics product (think Linear / Vercel / Spotify Wrapped) with AI-driven personalization (Persona), engagement visualization (Habit Radar, Momentum Meter), and narrative storytelling (Weekly + Final Replays) β€” all while preserving the existing backend, schema, and SP calculation logic exactly as they were. + +--- + +# Part 2 β€” Weekly Leaderboard, Weekly Recap Popups & SP Trend + +> **Branch:** `feat/share-export` +> **Scope:** New backend aggregator + 8 premium client components that ship the **Monday-morning Weekly Recap experience** (Champions popup β†’ Insights popup that flips β†’ Recovery Coach popup) plus the desktop Weekly Leaderboard with a 4Γ—6 SP Trend heatmap. All scoring is derived from existing `SPTransaction` and `Student` collections β€” **no schema migrations**. + +--- + +## 18. "A New Weekly Challenge Has Begun!" β€” FreshWeekEmpty + +**File:** `client/src/components/weekly-leaderboard/FreshWeekEmpty.tsx` + +The motivational empty-state that appears the moment a student opens the dashboard after **Monday 06:00 IST** (when the weekly window rolls over) before they've earned any SP this week. + +- Floating πŸš€ rocket (CSS `y: [0, -4, 0]`, 2.2s ease-in-out, infinite) +- Gold β†’ indigo β†’ cyan gradient title **"A New Weekly Challenge Has Begun!"** +- During the weekend **Calculating** phase, the title morphs to **"Calculating Weekly Champions…"** +- Two stat blocks: **CURRENT RANK** ("Not Ranked Yet") + **WEEKLY POINTS** ("0") +- Three animated amber blobs in the background for depth +- 2025-2026 helper copy: *"your first session = +10 SP"* + +--- + +## 19. Premium Desktop Weekly Leaderboard + +**Files:** +- `client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx` + `.css` +- `client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx` β€” scrollable rank table (search, Top-10 filter, sticky header, podium styling, count-up SP, sparkline trend bars, staggered row entrance) +- `client/src/components/weekly-leaderboard/RightRail.tsx` β€” 6 right-column widgets (Weekly Progress ring, AI Coach insights, Today's Goals checklist, Weekly Insights 8-tile grid, Activity Completion bars, rotating Motivation quotes) +- `client/src/components/weekly-leaderboard/RegularUserCard.tsx` β€” 4 metric tiles (Weekly SP / Weekly Rank / Rank Position ring / Rank Movement) + streak chip + CTA block +- `client/src/components/weekly-leaderboard/Top10Popup.tsx` β€” center-stage glass card with 36 confetti bits + 16 sparkles + 8 party poppers, "You" row with glowing border + pulse keyframe + +**Layout:** 3-column desktop dashboard β€” fixed sidebar (10 items, glassmorphism) + topbar (countdown, theme toggle, profile chip) + center body + right rail. + +**Theme:** Light + dark mode via CSS variables. Inline mode renders only the body inside the host Spurti dashboard. + +### Backend support +- `server/services/weeklyWindow.js` β€” Monday 06:00 β†’ Saturday 23:59 IST week window, phase detection (`pre-start` / `live` / `calculating`), countdown to next deadline +- `server/services/weeklyAggregator.js` β€” `SPTransaction` aggregation within the week window, per-student ranking, per-category counts +- `server/routes/weekly.js` β€” `GET /api/weekly/desktop?email=…` returns full ranked leaderboard + top 10 + middle + user summary with `bucket` (top10 / regular) in one round-trip + +--- + +## 20. Weekly Recap Popup Cascade β€” "A premium once-a-week popup that displays the Weekly Champions leaderboard, personalized AI performance insights, and a customized recovery plan before the dashboard loads" + +This is the **centerpiece of the feature**. When a student opens Spurti **after Monday 06:00 IST** for the first time that week, a 3-stage cascade fires automatically before the dashboard becomes interactive: + +### Stage 1 β€” WeeklyChampionsPopup (everyone, full page mode) +**File:** `client/src/components/weekly-recap/WeeklyChampionsPopup.tsx` + +- Premium glass card with gold gradient title **"🌟 Weekly Champions"** +- Top-10 list of last week's winners (Rank, Name, Weekly SP, Weekly Badge, Learning Consistency %) +- Rank 1 gets a golden pulse glow +- **"New Week Started"** block + **"Start My Week"** button +- Γ— close button +- Per-week `localStorage` dismissal flag β€” only shows once per week + +### Stage 2 β€” WeeklyLearningInsightsPopup (everyone, auto-flips after 10s) +**File:** `client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx` + `.css` + +This is the **flip card** the prompt asked about. It surfaces last week's Top 10 Champions on the **FRONT**, then automatically **flips after 10 seconds** to show personalized AI insights on the **BACK**. + +- **FRONT face:** Top 10 list with elegant glass rows +- **BACK face:** AI insights per case: + - `top10` β€” "What Went Right" + "Why You Stayed Ahead" + celebration effects (confetti + balloons + sparkles + party poppers) + - `close` β€” "What Went Right" + "Where You Lost Those Points" + "How You Could Have Reached Top 10" + - `other` β€” "What Went Right" + "Where You Can Improve" +- Smooth 0.85s `rotateY` transition +- Auto-flips at 10s β€” student can also click to flip manually +- Per-week `localStorage` dismissal flag + +### Stage 3 β€” RecoveryCoachPopup (case === 'bottom50' only) +**File:** `client/src/components/weekly-recap/RecoveryCoachPopup.jsx` + `.css` + +After the Insights popup closes, **only bottom-50 students** see the Recovery Coach popup. It never uses the word "Bottom 50" β€” instead provides a calm, AI-style recovery plan. + +- Calm blue/green/purple gradients (never red, never shaming) +- Positive observations (3 picked from attendance/polls/challenge/SP) +- Mon–Sat recovery plan with 2-3 specific items per day +- Estimated Outcome cards: Attendance % / Poll Completion % / SP Gain / Estimated Rank +- Encouragement "πŸ’™ You Can Do It!" message +- **"Start My Recovery Plan"** + **"Dismiss"** buttons +- Auto-dismisses after 12s +- `focusDay` prop β€” when provided (via SPTrendPanel click), the matching day row gets a one-shot pulse-glow + `scrollIntoView` + +### Why the cascade order? +1. **Champions** first β†’ honors the best (builds aspiration for others) +2. **Insights** second β†’ shows *your* personalized AI take (data-driven, neutral) +3. **Recovery Coach** last β†’ only for those who need encouragement (never mixed with Champions) + +This ordering avoids ever rubbing a struggling student's face in the top 10 list β€” they see Insights first, then get the warm Recovery Coach. Top-10 students see Champions + Insights with celebration effects and **never** the Recovery Coach. + +### Backend support +- `server/services/weeklyRecap.js` β€” `finalizePreviousWeek()` captures last week's leaders + bottom 50 + per-student activity breakdown. `recoveryPlanFor(email)` builds the AI Recovery Plan. `deriveWeeklyGoal()` picks one of three buckets. `liveProgressFor(email)` returns this-week counts. +- `server/services/weeklyRecapScheduler.js` β€” in-process tick every 5 min; past Monday 06:00 IST, idempotently finalizes the previous week +- `server/models/WeeklyRecap.js` β€” compacted schema for last week's recap +- `server/routes/recap.js` β€” `GET /api/weekly/recap?email=…` returns `{ recapId, recap, plan, me, case, goal, liveProgress }` in one round-trip + +### Frontend wiring +**File:** `client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx` β€” `useWeeklyRecapPopups` hook is the state machine that: +1. Waits 600ms after the dashboard mounts (so the page feels intentional, not jumpy) +2. Shows **Insights** first (everyone) +3. When Insights closes, if `case === 'bottom50'` and not yet dismissed, fires **Recovery** 400ms later +4. Each popup dismissal is recorded per `recapId` (weekStart ISO) so they only fire once per week + +--- + +## 21. 16-Rank Gamified Progression System + +**Files:** +- `client/src/components/rank-system/ranks.js` β€” 16 ranks from **Bronze III** (100 SP) to **Master** (1500 SP) +- `client/src/components/rank-system/RankJourney.jsx` β€” hero badge + horizontal track with 16 milestone markers +- `client/src/components/rank-system/rank-system.css` +- `server/services/levels.js` β€” `rankFor()`, `nextRank()`, `STARTING_SP=100`, `MAX_SP=1500` + +Replaces the old Bronze β†’ Legend hierarchy. Each tier has its own gradient + glow from `TIER_THEME`. The big hero badge shows current rank + description + next-rank hint. The track has a runner character with bob+dash animations and a current-rank footer with an animated progress bar. All animations are pure CSS keyframes (no framer-motion, no SMIL) to avoid white-screen crashes. + +Rank descriptions include "Master Strategist", "Knowledge Catalyst", "Insight Pioneer", "Elite Researcher", "Academic Virtuoso", "Wisdom Sentinel", "Sage Synthesizer", "Thought Leader", "Visionary Scholar", "Luminary", "Sage", "Oracle", "Mythic", "Legend", "Mythic Master", "Master". + +--- + +## 22. SP Trend Heatmap + Concept Advice + +**Files:** +- `server/services/spTrend.js` β€” pure aggregator reading `SPTransaction` +- `server/routes/spTrend.js` β€” `GET /api/weekly/sp-trend?email=…` +- `client/src/components/weekly-recap/SPTrendPanel.jsx` + `.css` + +Replaces the placeholder Weekly SP Trend card with a premium insight card: + +- **SVG trend line** β€” single connected path from program start, last 26 weeks +- **Slope chip** β€” `β†— / β†’ Flat / β†˜` based on the delta +- **Confetti burst** β€” only when `direction === 'up'` AND `consecutiveUpWeeks >= 2` (strict rule, never cheap) +- **4Γ—6 phase heatmap** β€” Attendance Γ— Polls Γ— Discussion Γ— Challenge across Mon–Sat +- **Clickable weakest cells** β€” any cell below the week's category median opens the RecoveryCoachPopup pre-focused on that day +- **Summary line** β€” always rendered, default: *"Steady progress over recent weeks."* +- Premium glass card, theme-dark compliant, full-keyframe animation reduced-motion fallback + +--- + +## Backend File Map (new) + +``` +server/ +β”œβ”€β”€ services/ +β”‚ β”œβ”€β”€ weeklyWindow.js (Mon 06:00 β†’ Sat 23:59 IST window) +β”‚ β”œβ”€β”€ weeklyAggregator.js (rank + per-student weekly summary) +β”‚ β”œβ”€β”€ weeklyRecap.js (finalize + AI plan + goal derivation) +β”‚ β”œβ”€β”€ weeklyRecapScheduler.js (idempotent in-process 5-min tick) +β”‚ β”œβ”€β”€ spTrend.js (trend + heatmap + summary) +β”‚ └── levels.js (16-rank system, replaces old Bronzeβ†’Legend) +β”œβ”€β”€ routes/ +β”‚ β”œβ”€β”€ weekly.js (/api/weekly/desktop) +β”‚ β”œβ”€β”€ recap.js (/api/weekly/recap) +β”‚ └── spTrend.js (/api/weekly/sp-trend) +β”œβ”€β”€ models/ +β”‚ └── WeeklyRecap.js +└── server.js (mounts all /api/weekly/* routes) +``` + +--- + +## Client File Map (new) + +``` +client/src/components/ +β”œβ”€β”€ weekly-leaderboard/ +β”‚ β”œβ”€β”€ WeeklyLeaderboardDesktop.tsx + .css (page shell + state machine) +β”‚ β”œβ”€β”€ WeeklyLeaderboard.tsx (scrollable rank table) +β”‚ β”œβ”€β”€ RightRail.tsx (6 right-column widgets) +β”‚ β”œβ”€β”€ Top10Popup.tsx (full-width celebration) +β”‚ β”œβ”€β”€ RegularUserCard.tsx (4 metric tiles + CTA) +β”‚ └── FreshWeekEmpty.tsx ("A New Weekly Challenge Has Begun!") +β”œβ”€β”€ weekly-recap/ +β”‚ β”œβ”€β”€ WeeklyChampionsPopup.tsx (full-mode Stages 1) +β”‚ β”œβ”€β”€ AIRecoveryCoachPopup.tsx (full-mode Coach) +β”‚ β”œβ”€β”€ WeeklyLearningInsightsPopup.jsx + .css (inline-mode flip card) +β”‚ β”œβ”€β”€ RecoveryCoachPopup.jsx + .css (inline-mode Coach) +β”‚ └── SPTrendPanel.jsx + .css (trend + heatmap) +β”œβ”€β”€ rank-system/ +β”‚ β”œβ”€β”€ ranks.js (16-rank table) +β”‚ β”œβ”€β”€ RankJourney.jsx (hero badge + track) +β”‚ └── rank-system.css +└── main.jsx (mounts RankJourney inline) +``` + +--- + +## Key Architectural Decisions (Part 2) + +1. **No schema migrations** β€” `WeeklyRecap` is the only new Mongoose collection; all other features are pure aggregations over `SPTransaction` + `Student` +2. **No breaking changes** β€” existing `/api/leaderboard`, `/api/weekly/desktop`, `/api/weekly/recap`, `/api/weekly/sp-trend` are all additive; the old `/api/leaderboard` still works +3. **Cascade order is intentional** β€” Champions (aspiration) β†’ Insights (reflection) β†’ Coach (only for bottom-50, never for top-10). This avoids ever rubbing a struggling student's face in the top 10 list +4. **One round-trip per popup stage** β€” `/api/weekly/recap` returns `{ recapId, recap, plan, me, case, goal, liveProgress }` so the cascade has no waterfall +5. **localStorage per-week dismissal** β€” each popup is keyed on `recapId` (weekStart ISO) so it shows only once per week, even after refresh +6. **CSS-only animations everywhere** β€” SMIL SVG `` was removed from rank system to avoid Chromium/Edge crashes; all keyframe animations honor `prefers-reduced-motion` +7. **Inline + full-page modes** β€” `WeeklyLeaderboardDesktop` accepts an `inline` prop so the same component renders inside the Spurti dashboard OR as a full-page standalone view + +--- + +## Performance (Part 2) + +- Build: **769 modules**, **81 KB CSS**, **942 KB JS** (gzip 14 KB / 283 KB) +- Premium cascade adds ~12 KB gzipped to the bundle +- 16-rank system is pure CSS β€” no JS animation overhead +- SPTrendPanel uses inline SVG β€” no Chart.js dependency +- Confetti / sparkles are pure CSS keyframes + framer-motion + +--- + +## What was NOT changed (Part 2) + +- ❌ No schema migrations on `students`, `sptransactions`, `attendance`, `polls`, `chats` +- ❌ No pipeline scoring changes (still uses `pipeline/sp-rubric-build-mirror.cjs`) +- ❌ No SP calculation changes +- ❌ No auth changes (still uses `chatengine_token` cookie passthrough) +- ❌ No font family / typography hierarchy changes +- ❌ No icon library added (emojis preserved) +- ❌ No Tailwind / styled-components added (vanilla CSS + framer-motion) +- ❌ No scheduled jobs / cron β€” `weeklyRecapScheduler` is in-process and idempotent + +--- + +## Final Summary + +**22 distinct features** across the original 17 + the weekly leaderboard build. The Spurti dashboard now offers a **professional Monday-morning recap experience** β€” Champions popup β†’ Insights popup that flips after 10s β†’ Recovery Coach popup (bottom-50 only) β€” all built on top of the existing 16-rank progression system and SP Trend heatmap. The dashboard scale goes from "student tracker" to "premium engagement SaaS" while preserving the existing MongoDB schema, SP calculation pipeline, and auth flow exactly as they were. \ No newline at end of file diff --git a/README.md b/README.md index d24b5ff..4912c3e 100644 --- a/README.md +++ b/README.md @@ -148,4 +148,17 @@ This direction can be evaluated through: ## Positioning This is a general educational motivation engine. It is not only for internships, and it is not only a points table. It is a self-regulated learning support system and research direction that helps students see their progress, stay encouraged, recover from setbacks, and complete any meaningful learning journey. -Displaying PRODUCT.md. + +--- + +## Premium Weekly Recap Experience β€” `feat/share-export` branch + +When a student opens Spurti after Monday 06:00 IST for the first time that week, a 3-stage premium cascade fires automatically before the dashboard becomes interactive: + +1. **WeeklyChampionsPopup** β€” top-10 leaderboard of last week's winners with celebration effects +2. **WeeklyLearningInsightsPopup** β€” Top-10 on the front, then **auto-flips after 10 seconds** to personalized AI performance insights on the back +3. **RecoveryCoachPopup** β€” only for bottom-50 students: calm, never-shaming AI recovery plan with Mon–Sat tasks and estimated outcomes + +A 16-rank progression system (**Bronze III β†’ Master**) replaces the old Bronzeβ†’Legend hierarchy, and a 4Γ—6 SP Trend heatmap visualizes the student's category-day activity with clickable weakest cells that open the Recovery Coach pre-focused on that day. + +Full feature documentation: see [`FEATURES.md`](./FEATURES.md) (Part 2, features 18–22). diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.css b/client/src/components/weekly-recap/RecoveryCoachPopup.css index 34b4eda..16bfd22 100644 --- a/client/src/components/weekly-recap/RecoveryCoachPopup.css +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.css @@ -127,6 +127,19 @@ border-radius: 10px; background: linear-gradient(135deg, rgba(56, 189, 248, 0.06), rgba(16, 185, 129, 0.04)); border: 1px solid rgba(56, 189, 248, 0.18); + transition: box-shadow 0.4s ease, transform 0.4s ease, background 0.4s ease; +} +.rcp__plan-day--focused { + background: linear-gradient(135deg, rgba(56, 189, 248, 0.22), rgba(99, 102, 241, 0.14)); + border-color: rgba(56, 189, 248, 0.55); + transform: translateY(-1px); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.32); + animation: rcp-day-pulse 1.2s ease-out 2; +} +@keyframes rcp-day-pulse { + 0% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.65); } + 60% { box-shadow: 0 0 0 10px rgba(56, 189, 248, 0.0); } + 100% { box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.32); } } .rcp__plan-day-name { font-size: 10px; font-weight: 800; letter-spacing: 0.10em; text-transform: uppercase; diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx index 716b6a9..caa32a3 100644 --- a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -1,15 +1,19 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import './RecoveryCoachPopup.css'; // ============================================================ // RecoveryCoachPopup (case 4) // Full-screen premium popup shown AFTER the WeeklyLearningInsightsPopup -// for students in the bottom 50 of the previous week. Never uses -// the words "Bottom 50". Instead provides a calm, AI-style recovery +// for students in the bottom 50 of the previous week. Never uses the +// words "Bottom 50". Instead provides a calm, AI-style recovery // plan with a Mon-Sat schedule, predicted outcomes, and an // encouraging message. Auto-dismisses after 12 seconds, or via the // "Start My Recovery Plan" / "Dismiss" buttons. +// +// focusDay prop: when provided, the matching day row gets a one-shot +// pulse-glow + scrollIntoView animation so the user sees the +// recovery tasks for that day immediately when the popup opens. // ============================================================ const RECOVERY_PLAN = [ @@ -35,7 +39,7 @@ function estimateOutcomes(me) { function buildObservations(me) { const list = []; - if ((me?.attendanceCount || 0) >= 1) list.push('You showed up this week β€” that’s the foundation.'); + if ((me?.attendanceCount || 0) >= 1) list.push("You showed up this week β€” that's the foundation."); if ((me?.pollCount || 0) >= 1) list.push('You already completed some polls β€” keep that streak going.'); if ((me?.challengeCount || 0) >= 1) list.push('You engaged with a weekly challenge β€” momentum is real.'); if ((me?.weeklySp || 0) > 0) list.push(`You already earned ${me.weeklySp} SP last week β€” that's a base.`); @@ -43,15 +47,29 @@ function buildObservations(me) { return list.slice(0, 3); } -export function RecoveryCoachPopup({ open, onClose, me, recapId, email }) { +export function RecoveryCoachPopup({ open, onClose, me, recapId, email, focusDay }) { const [dismissed, setDismissed] = useState(false); + const dayRefs = useRef({}); + // Auto-dismiss after 12 seconds. useEffect(() => { if (!open) return; const t = setTimeout(() => { setDismissed(true); onClose?.(); }, 12000); return () => clearTimeout(t); }, [open, onClose]); + // One-shot pulse-glow + scrollIntoView when focusDay is provided. + useEffect(() => { + if (!open || !focusDay) return; + const node = dayRefs.current[focusDay]; + if (node) { + try { node.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {} + node.classList.add('rcp-day--focused'); + const t = setTimeout(() => node.classList.remove('rcp-day--focused'), 2400); + return () => { clearTimeout(t); node.classList.remove('rcp-day--focused'); }; + } + }, [open, focusDay]); + if (!open || !me) return null; const outcomes = estimateOutcomes(me); @@ -107,7 +125,11 @@ export function RecoveryCoachPopup({ open, onClose, me, recapId, email }) {
πŸ“… Mon β†’ Sat Β· Recovery Plan
{RECOVERY_PLAN.map(d => ( -
+
{ dayRefs.current[d.day] = el; }} + >
{d.day}
{d.items.map((it, i) => (
diff --git a/client/src/components/weekly-recap/SPTrendPanel.css b/client/src/components/weekly-recap/SPTrendPanel.css new file mode 100644 index 0000000..0166e1e --- /dev/null +++ b/client/src/components/weekly-recap/SPTrendPanel.css @@ -0,0 +1,283 @@ +/* ============================================================ + SPTrendPanel β€” premium glass card matching the rest of the + dashboard's design language (blue β†’ violet β†’ indigo gradient). + ============================================================ */ + +.spt { + display: flex; + flex-direction: column; + gap: 14px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text); + --spt-grid: rgba(99, 102, 241, 0.12); + --spt-axis: rgba(71, 85, 105, 0.7); +} + +/* Top summary line β€” always rendered (your locked decision). */ +.spt-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.10), rgba(139, 92, 246, 0.06)); + border: 1px solid rgba(99, 102, 241, 0.18); +} +.spt-summary__icon { + width: 18px; height: 18px; + display: grid; place-items: center; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: #fff; + font-size: 10px; font-weight: 800; + flex-shrink: 0; +} +.spt-summary__text { + font-size: 12px; font-weight: 600; + color: var(--text); + line-height: 1.3; +} + +/* Trend line area */ +.spt-trend { + position: relative; + padding: 4px 6px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); +} +.spt-trend__svg { + width: 100%; + height: auto; + display: block; +} +.spt-trend__meta { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 4px 2px; +} +.spt-trend__last { + display: flex; + align-items: baseline; + gap: 4px; +} +.spt-trend__last-sp { + font-size: 18px; font-weight: 900; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1; +} +.spt-trend__last-label { + font-size: 9.5px; font-weight: 700; letter-spacing: 0.10em; text-transform: uppercase; + color: var(--text-dim); +} + +.spt-chip { + font-size: 11px; font-weight: 800; + letter-spacing: 0.02em; + padding: 4px 10px; + border-radius: 999px; + font-variant-numeric: tabular-nums; +} +.spt-chip--up { + background: rgba(16, 185, 129, 0.16); + color: #047857; + border: 1px solid rgba(16, 185, 129, 0.30); +} +.spt-chip--down { + background: rgba(244, 63, 94, 0.14); + color: #9f1239; + border: 1px solid rgba(244, 63, 94, 0.30); +} +.spt-chip--flat { + background: rgba(100, 116, 139, 0.16); + color: #475569; + border: 1px solid rgba(100, 116, 139, 0.30); +} + +/* Confetti β€” same animation as the Insights popup */ +.spt-confetti { + position: absolute; inset: 0; pointer-events: none; z-index: 2; + overflow: hidden; + border-radius: 12px; +} +.spt-confetti__bit { + position: absolute; + top: 0; + border-radius: 2px; + opacity: 0.92; + animation: spt-fall 2.4s linear infinite; +} +@keyframes spt-fall { + 0% { transform: translate(0, 0) rotate(0deg); opacity: 0; } + 10% { opacity: 0.95; } + 100% { transform: translate(var(--spt-drift, 0), 100%) rotate(720deg); opacity: 0; } +} + +/* Phase heatmap */ +.spt-heatmap { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px 12px; + border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); +} +.spt-heatmap__head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.spt-heatmap__eyebrow { + font-size: 9.5px; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--text-dim); +} +.spt-heatmap__best { + font-size: 10px; font-weight: 700; + color: #047857; + background: rgba(16, 185, 129, 0.10); + border: 1px solid rgba(16, 185, 129, 0.25); + padding: 2px 8px; + border-radius: 999px; +} + +.spt-heatmap__grid { + display: grid; + gap: 3px; +} +.spt-heatmap__row { + display: grid; + grid-template-columns: 80px repeat(6, 1fr); + gap: 3px; + align-items: stretch; +} +.spt-heatmap__row--header { margin-bottom: 1px; } +.spt-heatmap__cell { + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + min-height: 28px; +} +.spt-heatmap__cell--corner { background: transparent; } +.spt-heatmap__cell--label { + font-size: 9px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--text-muted); + background: transparent; + justify-content: flex-start; + padding-left: 4px; +} +.spt-heatmap__cell--label.is-today { + color: var(--accent); + font-weight: 900; +} +.spt-heatmap__cell--label.is-best { + color: #047857; + font-weight: 900; +} +.spt-heatmap__cell--data { + appearance: none; + border: 1px solid rgba(99, 102, 241, 0.10); + background: rgba(255, 255, 255, 0.65); + cursor: default; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); + position: relative; + transition: transform 0.1s, box-shadow 0.15s, border-color 0.15s; + --spt-cell-color: var(--accent, #6366f1); +} +.spt-heatmap__cell--data.is-today { + border-color: rgba(56, 189, 248, 0.45); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.20) inset; +} +.spt-heatmap__cell--data.is-best { + border-color: rgba(16, 185, 129, 0.55); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.30) inset, 0 0 8px rgba(16, 185, 129, 0.20); +} +.spt-heatmap__cell--data.is-weak:not(:disabled) { + border-color: rgba(99, 102, 241, 0.35); + cursor: pointer; +} +.spt-heatmap__cell--data.is-weak:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: 0 0 0 1px var(--spt-cell-color) inset, 0 0 12px rgba(99, 102, 241, 0.32); + border-color: var(--spt-cell-color); +} +.spt-heatmap__cell--b0 { background: rgba(148, 163, 184, 0.10); color: var(--text-dim); } +.spt-heatmap__cell--b1 { background: color-mix(in srgb, var(--spt-cell-color) 22%, transparent); } +.spt-heatmap__cell--b2 { background: color-mix(in srgb, var(--spt-cell-color) 45%, transparent); } +.spt-heatmap__cell--b3 { background: color-mix(in srgb, var(--spt-cell-color) 70%, transparent); } +.spt-heatmap__cell--b4 { + background: var(--spt-cell-color); + color: #fff; + font-weight: 900; +} +.spt-heatmap__cell-sp { + font-size: 9.5px; font-weight: 800; +} +.spt-heatmap__cell-best { + position: absolute; + top: 1px; right: 3px; + font-size: 8px; + font-weight: 900; + color: #047857; +} + +.spt-heatmap__legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 4px; + font-size: 9px; + font-weight: 700; + color: var(--text-dim); + letter-spacing: 0.04em; +} +.spt-heatmap__legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} +.spt-heatmap__legend-dot { + width: 10px; height: 10px; + border-radius: 3px; + display: inline-block; + border: 1px solid rgba(99, 102, 241, 0.15); +} +.spt-heatmap__legend-spacer { flex: 1; } +.spt-heatmap__legend-item--cta { + color: var(--accent); + font-style: italic; +} + +/* Bottom CTA */ +.spt-cta { + display: flex; + justify-content: flex-end; +} +.spt-cta__btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 16px; + border: 1px solid transparent; + border-radius: 10px; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #10b981 100%); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.30); + transition: filter 0.15s, transform 0.1s; +} +.spt-cta__btn:hover { filter: brightness(1.08); } +.spt-cta__btn:active { transform: translateY(1px); } +.spt-cta__arrow { font-size: 13px; } \ No newline at end of file diff --git a/client/src/components/weekly-recap/SPTrendPanel.jsx b/client/src/components/weekly-recap/SPTrendPanel.jsx new file mode 100644 index 0000000..a6f9114 --- /dev/null +++ b/client/src/components/weekly-recap/SPTrendPanel.jsx @@ -0,0 +1,332 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import './SPTrendPanel.css'; + +// ============================================================ +// SPTrendPanel +// Premium glass card that replaces the SP Trend placeholder inside the +// Weekly Leaderboard section. Two-tier visualization: +// 1. SP trajectory line β€” full program, weekly buckets, single +// connected SVG path with slope chip + confetti burst on +// consecutive-up recovery. +// 2. Phase heatmap β€” 4 categories x Mon-Sat, clickable weakest cell +// that opens the existing RecoveryCoachPopup pre-focused on the +// matching day. +// The whole panel lives inside the existing SP Trend card slot. +// ============================================================ + +const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const WEEKDAY_FULL = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const CATEGORY_LABELS = { + attendance: 'Attendance', + poll: 'Polls', + discussion: 'Discussions', + challenge: 'Challenge' +}; +const CATEGORY_COLOR = { + attendance: '#6366f1', + poll: '#8b5cf6', + discussion: '#10b981', + challenge: '#f59e0b' +}; + +// Map SP value β†’ 0..4 intensity bucket. 0 = empty, 4 = strongest. +function intensityBucket(sp, max) { + if (sp <= 0) return 0; + const ratio = sp / Math.max(max, 1); + if (ratio < 0.20) return 1; + if (ratio < 0.45) return 2; + if (ratio < 0.75) return 3; + return 4; +} + +// Small SVG confetti burst β€” one-shot per render. Reused only when +// the celebration rule fires (consecutive-up + up direction). +function MiniConfetti() { + const pieces = useMemo(() => Array.from({ length: 14 }, (_, i) => ({ + id: i, + left: 35 + Math.random() * 30, + delay: Math.random() * 0.4, + duration: 1.6 + Math.random() * 0.6, + drift: -8 + Math.random() * 16, + size: 4 + Math.random() * 4, + rotate: Math.random() * 360, + hue: ['#6366f1', '#8b5cf6', '#10b981', '#f59e0b', '#ec4899'][i % 5] + })), []); + return ( + + ); +} + +export function SPTrendPanel({ data, me, onOpenRecoveryCoach }) { + const [hasFiredConfetti, setHasFiredConfetti] = useState(false); + const pathRef = useRef(null); + + if (!data) return null; + const { trend = [], heatmap = [], summary = null } = data; + + // Compute chart geometry β€” single connected path through all points. + const maxSp = Math.max(8, ...trend.map(p => p.sp)); + const W = 600, H = 140; + const padL = 22, padR = 18, padT = 14, padB = 22; + const innerW = W - padL - padR; + const innerH = H - padT - padB; + const xs = trend.length > 1 ? (innerW / (trend.length - 1)) : 0; + const pointXY = (p, i) => ({ + x: padL + i * xs, + y: padT + innerH - (p.sp / maxSp) * innerH + }); + const pathD = trend.map((p, i) => { + const { x, y } = pointXY(p, i); + return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`; + }).join(' '); + + // Compute total path length so we can animate stroke-dashoffset. + const [pathLen, setPathLen] = useState(0); + useEffect(() => { + if (pathRef.current && typeof pathRef.current.getTotalLength === 'function') { + setPathLen(pathRef.current.getTotalLength()); + } + }, [pathD]); + + // Strict confetti rule: only when direction === 'up' AND consecutiveUpWeeks >= 2. + const showCelebration = summary?.direction === 'up' && (summary?.consecutiveUpWeeks || 0) >= 2; + + // Compute weakest cells per category (any cell below the category's + // weekly median β€” that's clickable, not only zero-SP cells). + const weakestCellsByCategory = useMemo(() => { + const out = {}; + if (!heatmap?.length) return out; + for (const row of heatmap) { + const values = row.days.map(d => d.sp).sort((a, b) => a - b); + const median = values.length % 2 + ? values[(values.length - 1) / 2] + : (values[values.length / 2 - 1] + values[values.length / 2]) / 2; + out[row.category] = row.days.filter(d => d.sp < median); + } + return out; + }, [heatmap]); + + // Identify the strongest cell (highest SP) for the star badge. + const strongestCell = useMemo(() => { + let s = null; + for (const row of heatmap) { + for (const d of row.days) { + if (!s || d.sp > s.sp) s = { ...d, category: row.category }; + } + } + return s; + }, [heatmap]); + + // Today's weekday index (0=Mon..5=Sat). 6 means Sunday β€” no highlight. + const todayIndex = (() => { + const d = new Date(); + const c = new Date(d.getTime() + 330 * 60_000).getUTCDay(); + return c === 0 ? 6 : c - 1; // 0..5 + })(); + + return ( +
+ {/* Top summary line β€” always rendered per your locked decision. */} +
+ ✦ + {summary?.insight || 'Steady progress over recent weeks.'} +
+ + {/* Trend line */} +
+ {showCelebration && } + + + + + + + + + {/* Y-axis grid */} + + + {/* Connected path */} + {trend.length > 1 && ( + { e.currentTarget.style.strokeDashoffset = '0'; }} + /> + )} + {/* Dots */} + {trend.map((p, i) => { + const { x, y } = pointXY(p, i); + const isLast = i === trend.length - 1; + return ( + + + {isLast && ( + + + + + )} + + ); + })} + {/* X-axis labels (last 5 weeks only, to avoid clutter) */} + {trend.length > 0 && (() => { + const step = Math.max(1, Math.ceil(trend.length / 5)); + const indices = []; + for (let i = 0; i < trend.length; i += step) indices.push(i); + if (indices[indices.length - 1] !== trend.length - 1) indices.push(trend.length - 1); + return indices.map(i => { + const { x } = pointXY(trend[i], i); + return ( + + {trend[i].weekLabel} + + ); + }); + })()} + +
+
+ {trend[trend.length - 1]?.sp ?? 0} + SP Β· {trend[trend.length - 1]?.weekLabel} +
+ + {summary?.direction === 'up' && <>β†— +{summary.delta} SP this week} + {summary?.direction === 'down' && <>β†˜ βˆ’{Math.abs(summary.delta || 0)} SP this week} + {summary?.direction === 'flat' && <>β†’ Flat} + +
+
+ + {/* Phase heatmap */} +
+
+ Where I'm weak today + {strongestCell && ( + + β˜… {strongestCell.sp} SP Β· {CATEGORY_LABELS[strongestCell.category] || strongestCell.category} Β· {strongestCell.weekdayShort} + + )} +
+
+
+
+ {WEEKDAY_LABELS.map((d, i) => ( +
{d}
+ ))} +
+ {heatmap.map(row => { + const rowMax = Math.max(8, ...row.days.map(d => d.sp)); + const weakSet = new Set((weakestCellsByCategory[row.category] || []).map(d => d.dayIdx)); + const isStrongestRow = strongestCell && strongestCell.category === row.category; + return ( +
+
+ {CATEGORY_LABELS[row.category] || row.category} +
+ {row.days.map((d, i) => { + const bucket = intensityBucket(d.sp, rowMax); + const isWeak = weakSet.has(i); + const isBest = strongestCell && strongestCell.category === row.category && strongestCell.dayIdx === i; + const isToday = i === todayIndex; + return ( + + ); + })} +
+ ); + })} +
+
+ + + empty + + + + below median + + + + active + + + + strong + + + + click any dim cell β†’ recovery plan + +
+
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/server/routes/spTrend.js b/server/routes/spTrend.js new file mode 100644 index 0000000..2fa684a --- /dev/null +++ b/server/routes/spTrend.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { getSpTrend } from '../services/spTrend.js'; + +const router = express.Router(); + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +// GET /api/weekly/sp-trend?email=... +// Returns: +// trend : weekly SP totals from program start (max 26 weeks) +// heatmap : per-category (attendance / poll / discussion / challenge) +// per-day (Mon-Sat) totals for the current IST week +// summary : delta, direction, bestDay, bestCategory, weakestCell, +// insight, consecutiveUpWeeks +// studentName : student's full name +router.get('/sp-trend', async (req, res) => { + const email = normalizeEmail(req.query.email); + if (!email) return res.status(400).json({ error: 'email required' }); + const data = await getSpTrend(email); + if (!data) return res.json({ trend: [], heatmap: [], summary: null }); + res.json(data); +}); + +export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index c389dfb..945f203 100644 --- a/server/server.js +++ b/server/server.js @@ -15,6 +15,7 @@ import SessionEvent from './models/SessionEvent.js'; import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js'; import weeklyRouter from './routes/weekly.js'; import recapRouter from './routes/recap.js'; +import spTrendRouter from './routes/spTrend.js'; import { startWeeklyRecapScheduler } from './services/weeklyRecapScheduler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -598,10 +599,12 @@ function last24Hours(now) { app.use('/api', api); app.use('/spurti/api', api); -app.use('/api/weekly', weeklyRouter); -app.use('/spurti/api/weekly', weeklyRouter); -app.use('/api/weekly', recapRouter); -app.use('/spurti/api/weekly', recapRouter); + app.use('/api/weekly', weeklyRouter); + app.use('/spurti/api/weekly', weeklyRouter); + app.use('/api/weekly', recapRouter); + app.use('/spurti/api/weekly', recapRouter); + app.use('/api/weekly', spTrendRouter); + app.use('/spurti/api/weekly', spTrendRouter); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist)); diff --git a/server/services/spTrend.js b/server/services/spTrend.js new file mode 100644 index 0000000..1a773f3 --- /dev/null +++ b/server/services/spTrend.js @@ -0,0 +1,226 @@ +import SPTransaction from '../models/SPTransaction.js'; +import Student from '../models/Student.js'; +import { weekContaining } from './weeklyWindow.js'; + +// ============================================================ +// SP Trend Aggregator +// Builds the data for the Student SP Trend UI: +// - trend: weekly SP totals from the start of the student's program +// - heatmap: per-category (attendance / poll / discussion / challenge) +// per-day (Mon-Sat) totals for the current week +// - summary: delta, direction, bestDay, bestCategory, weakestCell, +// insight, consecutiveUpWeeks +// Pure read β€” no side effects. +// ============================================================ + +const IST_OFFSET_MIN = 330; + +function istDayKey(d) { + const s = new Date(d.getTime() + IST_OFFSET_MIN * 60_000); + return `${s.getUTCFullYear()}-${String(s.getUTCMonth() + 1).padStart(2, '0')}-${String(s.getUTCDate()).padStart(2, '0')}`; +} + +const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const WEEKDAY_FULL = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +function bucketWeekStart(date) { + // Monday 00:00 IST is the start of a week for our purposes. + const d = new Date(date.getTime() + IST_OFFSET_MIN * 60_000); + const day = d.getUTCDay(); // 0=Sun, 1=Mon, ... 6=Sat + // Treat Sun (0) as the end of the prior week β€” push to the previous Monday. + const offset = day === 0 ? 6 : day - 1; + d.setUTCDate(d.getUTCDate() - offset); + return istDayKey(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0) - IST_OFFSET_MIN * 60_000)); +} + +// Five summary lines, picked deterministically so the same data +// surfaces the same line β€” but a different trend shape picks a +// different line. Always returns one (never blank). +const SUMMARY_LINES = { + bestInRecent: 'Best performance in the last 5 weeks.', + bouncingBack: 'Bouncing back β€” keep the rhythm.', + consistent: 'Maintained a consistent learning rhythm.', + steadyProgress: 'Steady progress over recent weeks.', + quartile: 'Building a steady weekly rhythm.' +}; + +function pickSummary(trend, summary) { + if (summary.consecutiveUpWeeks >= 3) return SUMMARY_LINES.bestInRecent; + // Detect a bounce: the week before last was lower than the week before that. + if (trend.length >= 3) { + const t = trend.map(p => p.sp); + const last = t[t.length - 1]; + const prev = t[t.length - 2]; + const before = t[t.length - 3]; + if (last > before && before < prev && summary.direction === 'up') { + return SUMMARY_LINES.bouncingBack; + } + } + // Stable Β±2 for the trailing window of β‰₯5 weeks. + if (trend.length >= 5) { + const tail = trend.slice(-5).map(p => p.sp); + const range = Math.max(...tail) - Math.min(...tail); + if (range <= 2) return SUMMARY_LINES.consistent; + } + if (summary.direction === 'up') return SUMMARY_LINES.steadyProgress; + return SUMMARY_LINES.quartile; +} + +export async function getSpTrend(email) { + if (!email) return null; + const student = await Student.findOne({ email }).select('internshipStartDate name').lean(); + if (!student) return null; + + const startMs = student.internshipStartDate + ? new Date(student.internshipStartDate).getTime() + : Date.now() - 90 * 86400_000; + + // Pull every transaction for this student since program start. + const txns = await SPTransaction.find({ + email, + dateTime: { $gte: new Date(startMs) } + }) + .select('appliedDelta category dateTime') + .lean(); + + // Bucket by week. + const weekMap = new Map(); + for (const t of txns) { + const wk = bucketWeekStart(t.dateTime); + weekMap.set(wk, (weekMap.get(wk) || 0) + Math.max(0, t.appliedDelta || 0)); + } + + // Fill missing weeks with 0 so the trend line is continuous. + const currentMs = Date.now(); + const trend = []; + // Iterate week by week from start to current. + let cursorMs = startMs; + let idx = 1; + let lastRealSp = 0; + let prevSp = 0; + let prevPrevSp = 0; + let consecutiveUpWeeks = 0; + let hadTickingActivity = false; + while (cursorMs <= currentMs) { + const wk = bucketWeekStart(new Date(cursorMs)); + const sp = weekMap.get(wk) || 0; + if (sp > 0) hadTickingActivity = true; + trend.push({ weekStart: wk, sp, weekLabel: `W${idx}` }); + // Update consecutive-up counter. + if (idx >= 2) { + if (sp > prevSp) { + consecutiveUpWeeks = (consecutiveUpWeeks || 0) + 1; + } else if (sp === prevSp) { + // No change. + } else { + consecutiveUpWeeks = 0; + } + } + prevPrevSp = prevSp; + prevSp = sp; + lastRealSp = sp; + cursorMs += 7 * 86400_000; + idx += 1; + } + // Truncate to the last 26 weeks (a half-year) for cleaner visuals. + const visualTrend = trend.slice(-26); + + // Heatmap for the current week: Mon-Sat per category. + const currentWeek = weekContaining(); + const startWeekMs = currentWeek.startMs; + const endWeekMs = startWeekMs + 7 * 86400_000; + const weekTxns = txns.filter(t => { + const ms = t.dateTime.getTime(); + return ms >= startWeekMs && ms < endWeekMs; + }); + const heatmap = ['attendance', 'poll', 'discussion', 'challenge'].map(category => { + const days = []; + for (let dayIdx = 0; dayIdx < 6; dayIdx++) { + const dayStart = startWeekMs + dayIdx * 86400_000; + const dayEnd = dayStart + 86400_000; + const sp = weekTxns.filter(t => { + if (t.category !== category) return false; + const ms = t.dateTime.getTime(); + return ms >= dayStart && ms < dayEnd; + }).reduce((s, t) => s + Math.max(0, t.appliedDelta || 0), 0); + days.push({ + date: istDayKey(new Date(dayStart - IST_OFFSET_MIN * 60_000)), + weekday: WEEKDAY_FULL[dayIdx], + weekdayShort: WEEKDAY_LABELS[dayIdx], + sp, + dayIdx + }); + } + return { category, days }; + }); + + // Derive summary. + const last = visualTrend.length > 0 ? visualTrend[visualTrend.length - 1].sp : 0; + const prev = visualTrend.length > 1 ? visualTrend[visualTrend.length - 2].sp : 0; + const delta = last - prev; + const direction = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'; + + // Find best day + best category for this week (excluding zeros). + const totals = { Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 }; + const categoryTotals = { attendance: 0, poll: 0, discussion: 0, challenge: 0 }; + for (const row of heatmap) { + categoryTotals[row.category] = row.days.reduce((s, d) => s + d.sp, 0); + for (const d of row.days) totals[d.weekdayShort] += d.sp; + } + let bestDay = null; + let bestDaySp = 0; + for (const [day, sp] of Object.entries(totals)) { + if (sp > bestDaySp) { bestDay = day; bestDaySp = sp; } + } + let bestCategory = null; + let bestCategorySp = 0; + for (const [cat, sp] of Object.entries(categoryTotals)) { + if (sp > bestCategorySp) { bestCategory = cat; bestCategorySp = sp; } + } + + // Find weakest cell β€” for each category, find the day with the + // lowest SP below the category's median. This is the "clickable + // weak cell" β€” students can improve even if they participated but + // performed below average. + const weakestCells = []; + for (const row of heatmap) { + const values = row.days.map(d => d.sp); + const sorted = [...values].sort((a, b) => a - b); + const median = sorted.length % 2 + ? sorted[(sorted.length - 1) / 2] + : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2; + row.days.forEach(d => { + if (d.sp < median) { + weakestCells.push({ + category: row.category, + weekday: d.weekday, + weekdayShort: d.weekdayShort, + dayIdx: d.dayIdx, + sp: d.sp + }); + } + }); + } + // Sort weakest cells ascending by SP, then by category / weekday. + weakestCells.sort((a, b) => a.sp - b.sp); + const weakestCell = weakestCells[0] || null; + + const summary = { + delta, + direction, + bestDay, + bestCategory, + weakestCell, + insight: '', + consecutiveUpWeeks: hadTickingActivity ? consecutiveUpWeeks : 0 + }; + summary.insight = pickSummary(visualTrend, summary); + + return { + email, + trend: visualTrend, + heatmap, + summary, + studentName: student.name + }; +} \ No newline at end of file From 45c0b0cf66e84ba4565190b1b321294be91cf2da Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 17:18:30 +0530 Subject: [PATCH 23/31] feat: wire SPTrendPanel + RecoveryCoachPopup into StudentView SPTrendPanel was created in the previous commit but never rendered anywhere. Mount it as a new full-width 'SP Trend' panel section in the StudentView, right after StudentPulse and before the inline weekly leaderboard. Fetches /api/weekly/sp-trend and /api/weekly/recap on mount, then passes the recap me data through so weakest-cell clicks can open the RecoveryCoachPopup pre-focused on the matching weekday. This is the missing wire that closes the loop: 1. SPTrendPanel shows the 4x6 heatmap with weakest cells clickable 2. Click a dim cell -> RecoveryCoachPopup opens with focusDay set 3. The matching day row gets a one-shot pulse-glow + scrollIntoView Build: 771 modules, +5.7 KB CSS, +9 KB JS. --- client/src/main.jsx | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/client/src/main.jsx b/client/src/main.jsx index 879f5d9..eb8915f 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -11,6 +11,12 @@ import './components/replay/replay.css'; import { WeeklyLeaderboardDesktop } from './components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx'; import './components/weekly-leaderboard/WeeklyLeaderboardDesktop.css'; import { RankJourney } from './components/rank-system/RankJourney'; +import { SPTrendPanel } from './components/weekly-recap/SPTrendPanel'; +import './components/weekly-recap/SPTrendPanel.css'; +import { RecoveryCoachPopup } from './components/weekly-recap/RecoveryCoachPopup'; +import './components/weekly-recap/RecoveryCoachPopup.css'; +import { WeeklyLearningInsightsPopup } from './components/weekly-recap/WeeklyLearningInsightsPopup'; +import './components/weekly-recap/WeeklyLearningInsightsPopup.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; @@ -292,9 +298,43 @@ class StudentViewErrorBoundary extends React.Component { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); const [weeklyOpen, setWeeklyOpen] = useState(false); + const [spTrend, setSpTrend] = useState(null); + const [recap, setRecap] = useState(null); + const [recoveryOpen, setRecoveryOpen] = useState(false); + const [recoveryFocusDay, setRecoveryFocusDay] = useState(null); + const [recoveryMe, setRecoveryMe] = useState(null); + const [recoveryRecapId, setRecoveryRecapId] = useState(null); const { student } = profile; const badges = useMemo(() => buildBadges(profile), [profile]); const nextActions = useMemo(() => buildNextActions(profile), [profile]); + + useEffect(() => { + if (!student?.email) return; + let cancelled = false; + fetch(`${API}/weekly/sp-trend?email=${encodeURIComponent(student.email)}`) + .then(r => r.ok ? r.json() : null) + .then(j => { if (!cancelled && j) setSpTrend(j); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [student?.email]); + + useEffect(() => { + if (!student?.email) return; + let cancelled = false; + fetch(`${API}/weekly/recap?email=${encodeURIComponent(student.email)}`) + .then(r => r.ok ? r.json() : null) + .then(j => { if (!cancelled && j) setRecap(j); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [student?.email]); + + const openRecoveryForDay = (focus) => { + setRecoveryFocusDay(focus?.weekday || null); + setRecoveryMe(recap?.me || profile?.student || null); + setRecoveryRecapId(recap?.recapId || null); + setRecoveryOpen(true); + }; + return (
@@ -309,7 +349,24 @@ function StudentView({ profile, onBack }) { + {spTrend && ( +
+
+

SP Trend

+ Your weekly SP trajectory + where to focus +
+ +
+ )} + setRecoveryOpen(false)} + me={recoveryMe} + recapId={recoveryRecapId} + email={student.email} + focusDay={recoveryFocusDay} + /> {tab === 'bank' && } {tab === 'polls' && } From b84ea7b3d7aa62e2ace09346ad500a3376f92226 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 17:28:14 +0530 Subject: [PATCH 24/31] feat: add ?popups=always bypass for weekly recap popups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights and Recovery Coach popups were keyed on a per-week localStorage flag (wli_dismissed_ / rcp_dismissed_), so once a student dismissed them they never reappeared for the rest of the week β€” even on every page reload. Add a \popupsAlwaysMode()\ helper that checks the URL for \?popups=always\ (or \?popups=1\ / \?popups=true\). When set: - wasInsightsDismissed / wasRecoveryCoachDismissed always return false so the cascade fires on every page open. - markInsightsDismissed / markRecoveryCoachDismissed are no-ops so the flag is never persisted. Production behavior (once-per-week dismissal) is preserved when the query param is absent. This makes it trivial to preview the Monday- morning cascade locally without waiting for Monday. --- .../components/weekly-recap/RecoveryCoachPopup.jsx | 11 +++++++++++ .../weekly-recap/WeeklyLearningInsightsPopup.jsx | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx index caa32a3..36a0fbf 100644 --- a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -191,13 +191,24 @@ export function RecoveryCoachPopup({ open, onClose, me, recapId, email, focusDay } export function wasRecoveryCoachDismissed(recapId) { + if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`rcp_dismissed_${recapId}`); } catch { return false; } } export function markRecoveryCoachDismissed(recapId) { + if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`rcp_dismissed_${recapId}`, '1'); } catch {} +} + +export function popupsAlwaysMode() { + if (typeof window === 'undefined') return false; + try { + const sp = new URLSearchParams(window.location.search); + const v = (sp.get('popups') || '').toLowerCase(); + return v === 'always' || v === '1' || v === 'true'; + } catch { return false; } } \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx index f61ed14..b6fbe0d 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -438,13 +438,27 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey, } // ----- Dismissal flag helpers ----- +// When the URL has `?popups=always` (or `?popups=1`), the popup shows on +// every page open β€” useful for local dev. Production behavior (once-per-week) +// is preserved otherwise. +export function popupsAlwaysMode() { + if (typeof window === 'undefined') return false; + try { + const sp = new URLSearchParams(window.location.search); + const v = (sp.get('popups') || '').toLowerCase(); + return v === 'always' || v === '1' || v === 'true'; + } catch { return false; } +} + export function wasInsightsDismissed(recapId) { + if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`wli_dismissed_${recapId}`); } catch { return false; } } export function markInsightsDismissed(recapId) { + if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`wli_dismissed_${recapId}`, '1'); } catch {} From d5b05e274af2f7e2c52a2f6edf5b0ae113e1bb36 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 17:45:46 +0530 Subject: [PATCH 25/31] feat: local-dev auth bypass (SPURTI_DEV_AUTH=1 + ?devEmail=) On localhost the Samagama auth server (port 5001) is not running, so /api/me always returns {authenticated:false} and the dashboard never mounts. Without the dashboard mounted, the WeeklyLeaderboardDesktop component never renders, so the weekly recap popup cascade never fires. Add a guarded local-dev bypass in studentEmailFromRequest that activates ONLY when SPURTI_DEV_AUTH=1 is set on the server: - Accepts ?devEmail= query param or x-dev-email header - Resolves to the matching Student record - Never active in production (env var must be explicitly set) Client side: when ?devEmail=... is in the URL, attach x-dev-email to the /api/me fetch so the dashboard bootstraps as that student. Combined with ?popups=always, the full Monday-morning recap cascade is now previewable end-to-end on localhost without waiting for Monday and without the Samagama auth server running. --- client/src/main.jsx | 12 +++++++++++- server/server.js | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index eb8915f..43a122e 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -21,6 +21,16 @@ import './components/weekly-recap/WeeklyLearningInsightsPopup.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; +// Local-dev auth bypass: when SPURTI_DEV_AUTH=1 is set on the server, it +// accepts ?devEmail=… (or x-dev-email header) as the authenticated student. +// Hook the URL into every /api/me fetch so the dashboard can preview. +const DEV_EMAIL = (() => { + try { + return new URLSearchParams(window.location.search).get('devEmail') || ''; + } catch { return ''; } +})(); +const DEV_HEADERS = DEV_EMAIL ? { 'x-dev-email': DEV_EMAIL } : {}; + function App() { const [view, setView] = useState(() => new URLSearchParams(window.location.search).get('admin') === '1' ? 'admin-login' : 'landing'); const [profile, setProfile] = useState(null); @@ -57,7 +67,7 @@ function App() { setConfig(nextConfig); if (view !== 'admin-login') { - const meRes = await fetch(`${API}/me`); + const meRes = await fetch(`${API}/me`, { headers: DEV_HEADERS }); if (meRes.ok) { const data = await meRes.json(); if (data.authenticated && data.profile && active) { diff --git a/server/server.js b/server/server.js index 945f203..cc78ce2 100644 --- a/server/server.js +++ b/server/server.js @@ -126,6 +126,14 @@ async function getSamagamaUser(chatengineToken) { } async function studentEmailFromRequest(req) { + // Local-dev bypass: when SPURTI_DEV_AUTH=1 is set, accept an `x-dev-email` + // header (or `?devEmail=…` query param) as the authenticated student. + // This lets you preview the dashboard without the Samagama auth server. + // Never active in production (the env var must be explicitly set). + if (process.env.SPURTI_DEV_AUTH === '1') { + const devEmail = (req.headers['x-dev-email'] || req.query?.devEmail || '').toString().trim(); + if (devEmail) return normalizeEmail(devEmail); + } const cookies = parseCookies(req.headers.cookie || ''); const data = await getSamagamaUser(cookies.chatengine_token); // Samagama's /api/auth/me nests the user as { user: { email, ... } }; From 69556c08605c12d71344c5dc7820c0d842bfd51c Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 17:52:44 +0530 Subject: [PATCH 26/31] fix: wire 'Got it' close button + move cross bar inside popup Two issues with the WeeklyLearningInsightsPopup: 1. The 'Got it - Start My Week' button on the back face of the card (rendered after the 10s auto-flip) had no onClick handler, so it didn't close the popup when clicked. Pass onClose through to the InsightsCard sub-component and wire it to the button. 2. The X close button was positioned at 'top: -52px' which placed it ABOVE the popup card, outside the visible area. Move it to 'top: 14px; right: 14px' so it's visible inside the popup with a glassmorphism background that matches the design language. The 'Skip to Dashboard' / 'Continue to Dashboard' button at the bottom of the popup was already wired correctly with onClose. --- .../weekly-recap/WeeklyLearningInsightsPopup.css | 16 +++++++++------- .../weekly-recap/WeeklyLearningInsightsPopup.jsx | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css index 911ea91..0ceaa42 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css @@ -27,20 +27,22 @@ @media (max-width: 720px) { .wli-stack { width: min(92vw, 600px); } } .wli-overlay__close { - position: absolute; top: -52px; right: 0; - width: 40px; height: 40px; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.10); - color: rgba(255, 255, 255, 0.92); + position: absolute; top: 14px; right: 14px; + width: 36px; height: 36px; + border: 1px solid rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.95); font-size: 22px; line-height: 1; border-radius: 50%; cursor: pointer; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); transition: background 0.15s, transform 0.1s; - z-index: 4; + z-index: 6; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; } -.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.22); } +.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.24); } .wli-overlay__close:active { transform: scale(0.94); } /* Card flip wrapper */ diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx index b6fbe0d..3207b3d 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -287,7 +287,7 @@ function ChampionCard({ recap, me, caseKey }) { ); } -function InsightsCard({ insights, caseKey }) { +function InsightsCard({ insights, caseKey, onClose }) { return (
@@ -358,7 +358,7 @@ function InsightsCard({ insights, caseKey }) {

{insights.cta}

- +
); @@ -412,7 +412,7 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey,
- {insights && } + {insights && }
From 9d44c0133d2160e1a7d9a6faf575927692ad89d7 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 18:15:42 +0530 Subject: [PATCH 27/31] feat: send weekly recovery emails to bottom-50 students on recap finalize When the recap scheduler finalizes the previous week (Mon 06:00 IST), dispatch a personalized recovery email to every student in the bottom 50. Uses the email field already on the Student record, so no separate mailing-list table is needed. mailer.js: - sendRecoveryEmail({name, email, weekStart, rank, weeklySp, plan, recapId}) composes a plain-text + HTML body with the student's name, last week's rank/SP, observations, Mon->Sat plan, and estimated outcomes. - If SAMAGAMA_MAILER_URL is set, POSTs to it (the Samagama mailer endpoint). Otherwise logs the body to stdout in dry-run mode so local dev works without breaking. - sendRecoveryEmailsToBottom50(recap) loops through recap.bottom50. weeklyRecap.js: - finalizePreviousWeek now calls sendRecoveryEmailsToBottom50 after the recap is upserted. Logs the sent/failed counts. WeeklyRecap model: - New fields recoveryEmailsSentAt, recoveryEmailsSentCount, recoveryEmailsFailedCount for idempotency. The scheduler only fires once per recap (the field is set after the first send). --- server/models/WeeklyRecap.js | 7 +- server/services/mailer.js | 172 +++++++++++++++++++++++++++++++++ server/services/weeklyRecap.js | 22 +++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 server/services/mailer.js diff --git a/server/models/WeeklyRecap.js b/server/models/WeeklyRecap.js index c753b53..945ba84 100644 --- a/server/models/WeeklyRecap.js +++ b/server/models/WeeklyRecap.js @@ -31,7 +31,12 @@ const weeklyRecapSchema = new mongoose.Schema({ bottom50: { type: [recapEntrySchema], default: [] }, // Full ranking saved for any future debug/replay allRanked: { type: [recapEntrySchema], default: [] }, - finalizedAt: { type: Date, default: Date.now } + finalizedAt: { type: Date, default: Date.now }, + // When the recovery emails were dispatched to the bottom-50 list. + // Used for idempotency: the scheduler skips re-sends if this is set. + recoveryEmailsSentAt: { type: Date, default: null }, + recoveryEmailsSentCount: { type: Number, default: 0 }, + recoveryEmailsFailedCount: { type: Number, default: 0 } }, { timestamps: true }); weeklyRecapSchema.index({ weekStart: 1 }, { unique: true }); diff --git a/server/services/mailer.js b/server/services/mailer.js new file mode 100644 index 0000000..8c9cbc6 --- /dev/null +++ b/server/services/mailer.js @@ -0,0 +1,172 @@ +// ============================================================ +// mailer β€” abstraction over the Samagama mailer (or local log +// fallback). Sends the weekly recovery plan to bottom-50 students +// the moment the recap finalizes. +// +// In production, set SAMAGAMA_MAILER_URL to forward to the +// Samagama side's mailer endpoint. In local dev, no env var is +// expected β€” the email body is logged to stdout so you can see +// exactly what students would receive. +// ============================================================ + +const SAMAGAMA_MAILER_URL = process.env.SAMAGAMA_MAILER_URL || ''; +const SAMAGAMA_MAILER_TOKEN = process.env.SAMAGAMA_MAILER_TOKEN || ''; +const FROM_ADDRESS = process.env.RECOVERY_FROM_EMAIL || 'spurti@iitrpr.ac.in'; +const FROM_NAME = process.env.RECOVERY_FROM_NAME || 'Spurti Β· IIT Ropar'; + +// Build the recovery email body for a bottom-50 student. +function buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }) { + const subject = `Your Spurti weekly recap β€” let\u2019s plan the week ahead (${weekStart})`; + const greeting = name ? `Hi ${name},` : 'Hi,'; + const firstName = (name || '').split(' ')[0] || 'there'; + const planLines = (plan?.days || []).map(d => + ` \u2022 ${d.day}: ${(d.items || []).join(' \u2192 ')}` + ).join('\n'); + const observations = (plan?.observations || []).map(o => ` \u2022 ${o}`).join('\n'); + const outcomes = plan?.outcomes || {}; + const text = [ + greeting, + '', + `Your Spurti recap for the week of ${weekStart} is ready. You finished`, + `at rank ${rank} with ${weeklySp} SP β€” this puts you in the bottom 50 of`, + `the cohort. That is not a failure; it is just a signal that this week`, + `was quieter than the rest. Every great learner has weeks like this.`, + '', + 'WHAT YOU ALREADY HAVE', + observations || ' \u2022 You logged in this week \u2014 the first step is done.', + '', + 'YOUR RECOVERY PLAN (Mon \u2192 Sat)', + planLines || ' \u2022 Attend the live session every day and complete all polls.', + '', + 'ESTIMATED OUTCOMES IF YOU FOLLOW THE PLAN', + ` \u2022 Attendance: ${outcomes.estAtt ?? '\u2014'}%`, + ` \u2022 Poll completion: ${outcomes.estPol ?? '\u2014'}%`, + ` \u2022 Expected Spurti Points: +${outcomes.estSp ?? '\u2014'}`, + ` \u2022 Estimated rank: Top ${outcomes.estRank ?? '\u2014'}`, + '', + 'YOU CAN DO IT', + `Small improvements every day create remarkable results, ${firstName}.`, + 'Open your Spurti dashboard to see the full plan and tick off tasks as', + 'you complete them.', + '', + '\u2014 Spurti, IIT Ropar', + '', + `(If the link above does not work, open https://samagama.in/spurti/?devEmail=${encodeURIComponent(email)}` + ].join('\n'); + + const html = ` +
+
Spurti Weekly Recap
+

${greeting}

+

+ Your recap for the week of ${weekStart} is ready. + You finished at rank ${rank} with ${weeklySp} SP — + this puts you in the bottom 50 of the cohort. That is not a failure; + it is just a signal that this week was quieter than the rest. +

+
+
\u2705 What You Already Have
+
${(plan?.observations || ['You logged in this week \u2014 the first step is done.']).map(o => `
\u2022 ${o}
`).join('')}
+
+
+
\ud83d\udcc5 Mon \u2192 Sat \u00b7 Recovery Plan
+
+ ${(plan?.days || []).map(d => `
${d.day}: ${(d.items || []).join(' \u2192 ')}
`).join('')} +
+
+
+
\ud83c\udfaf Estimated Outcomes
+ + + + + +
Attendance${outcomes.estAtt ?? '\u2014'}%
Poll completion${outcomes.estPol ?? '\u2014'}%
Expected SP+${outcomes.estSp ?? '\u2014'}
Estimated rankTop ${outcomes.estRank ?? '\u2014'}
+
+

+ Small improvements every day create remarkable results, ${firstName}. + Open your Spurti dashboard to see the full plan and tick off tasks as you complete them. +

+ +
+ Sent by ${FROM_NAME} \u00b7 IIT Ropar \u00b7 VLED Summership +
+
+ `; + return { subject, text, html }; +} + +// Send a single email. If SAMAGAMA_MAILER_URL is set, POST to it. Otherwise +// log to stdout so the body is visible in server.out for local dev. +export async function sendRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan, recapId }) { + if (!email) return { ok: false, reason: 'no email' }; + const payload = buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }); + const envelope = { + from: `${FROM_NAME} <${FROM_ADDRESS}>`, + to: email, + subject: payload.subject, + text: payload.text, + html: payload.html, + recapId, + weekStart + }; + if (!SAMAGAMA_MAILER_URL) { + console.log(`[mailer] (dry-run) to=${email} subject="${payload.subject}"`); + console.log(`[mailer] body-start\n${payload.text}\n[mailer] body-end`); + return { ok: true, mocked: true }; + } + try { + const res = await fetch(SAMAGAMA_MAILER_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SAMAGAMA_MAILER_TOKEN ? { 'Authorization': `Bearer ${SAMAGAMA_MAILER_TOKEN}` } : {}) + }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(10000) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[mailer] send failed to=${email} status=${res.status} body=${body.slice(0, 200)}`); + return { ok: false, status: res.status }; + } + console.log(`[mailer] sent to=${email} recapId=${recapId}`); + return { ok: true }; + } catch (err) { + console.error(`[mailer] send error to=${email}: ${err?.message}`); + return { ok: false, reason: err?.message }; + } +} + +// Send to every bottom-50 student of a recap. Idempotent at the per-email +// level (caller can pass `excludeEmails` to skip ones already sent). +export async function sendRecoveryEmailsToBottom50(recap, { excludeEmails = new Set() } = {}) { + if (!recap?.bottom50?.length) return { sent: 0, skipped: 0, failed: 0 }; + let sent = 0, skipped = 0, failed = 0; + for (const row of recap.bottom50) { + if (!row?.email) continue; + if (excludeEmails.has(row.email)) { skipped++; continue; } + try { + const { recoveryPlanFor } = await import('./weeklyRecap.js'); + const plan = await recoveryPlanFor(row.email); + const result = await sendRecoveryEmail({ + name: row.name, + email: row.email, + weekStart: recap.weekStart, + weekEnd: recap.weekEnd, + rank: row.rank, + weeklySp: row.weeklySp, + plan, + recapId: recap.weekStart + }); + if (result.ok) sent++; + else failed++; + } catch (err) { + failed++; + console.error(`[mailer] failed to send to ${row.email}: ${err?.message}`); + } + } + return { sent, skipped, failed }; +} diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js index f30c77a..b45951d 100644 --- a/server/services/weeklyRecap.js +++ b/server/services/weeklyRecap.js @@ -162,6 +162,28 @@ export async function finalizePreviousWeek({ force = false } = {}) { { upsert: true, new: true } ); + // Dispatch the recovery emails to the bottom-50 students. Idempotent: + // skip if this recap has already been mailed. + try { + if (!recap.recoveryEmailsSentAt) { + const { sendRecoveryEmailsToBottom50 } = await import('./mailer.js'); + const { sent, failed } = await sendRecoveryEmailsToBottom50(recap); + await WeeklyRecap.updateOne( + { _id: recap._id }, + { + $set: { + recoveryEmailsSentAt: new Date(), + recoveryEmailsSentCount: sent, + recoveryEmailsFailedCount: failed + } + } + ); + console.log(`[recap] mailed bottom-50: sent=${sent} failed=${failed}`); + } + } catch (err) { + console.error('[recap] recovery mailer failed:', err?.message); + } + return recap; } From e5cebb37be62ff1a1b9b75d4f90e6b4761b8d031 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 18:26:27 +0530 Subject: [PATCH 28/31] docs: add PR template body for vicharanashala PR --- PR_VICHARANASHALA.md | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 PR_VICHARANASHALA.md diff --git a/PR_VICHARANASHALA.md b/PR_VICHARANASHALA.md new file mode 100644 index 0000000..9490bc3 --- /dev/null +++ b/PR_VICHARANASHALA.md @@ -0,0 +1,54 @@ +# feat: Weekly Recap popup cascade + SP Trend heatmap + 16-rank system + bottom-50 recovery emails + +## Summary + +Adds the Monday-morning weekly recap experience: Champions popup β†’ +Insights popup (auto-flips at 10s) β†’ Recovery Coach popup. Plus the +desktop Weekly Leaderboard, 16-rank progression, 4Γ—6 SP Trend heatmap, +and personalized bottom-50 recovery emails. No schema migrations on +existing collections, no SP calculation changes. + +## What's new + +- **FreshWeekEmpty** β€” "A New Weekly Challenge Has Begun!" motivational frame +- **WeeklyLeaderboardDesktop** β€” premium 3-column desktop shell (inline + full-page), with Top 10 popup, scrollable rank table, 6-widget right rail +- **WeeklyLearningInsightsPopup** β€” auto-flipping card (Top 10 on front β†’ AI insights on back after 10s) +- **RecoveryCoachPopup** β€” calm AI recovery plan for bottom-50 students +- **AIRecoveryCoachPopup** β€” full-page equivalent for the standalone view +- **SPTrendPanel** β€” 4Γ—6 heatmap + SVG trend line + slope chip, with weakest-cell click β†’ Recovery Coach pre-focused +- **16-rank system** β€” Bronze III β†’ Master (100–1500 SP), pure CSS animations +- **Recovery email mailer** β€” sends personalized emails to bottom-50 students on recap finalize (dry-run mode in dev, forwards to Samagama mailer when `SAMAGAMA_MAILER_URL` is set) + +## Endpoints added + +- `GET /api/weekly/desktop?email=…` β€” leaderboard + user summary +- `GET /api/weekly/recap?email=…` β€” recap + AI plan + case + goal + liveProgress +- `GET /api/weekly/sp-trend?email=…` β€” trend + heatmap + summary + +## Files changed (6 commits) + +- 9d44c01 β€” feat: send weekly recovery emails to bottom-50 students on recap finalize +- 69556c0 β€” fix: wire 'Got it' close button + move cross bar inside popup +- d5b05e2 β€” feat: local-dev auth bypass (SPURTI_DEV_AUTH=1 + ?devEmail=) +- b84ea7b β€” feat: add ?popups=always bypass for weekly recap popups +- 45c0b0c β€” feat: wire SPTrendPanel + RecoveryCoachPopup into StudentView +- 7630610 β€” feat: add SP Trend panel + document weekly recap popup cascade + +## Local dev workflow + +```bash +# 1. kill any leftover server +taskkill /F /IM node.exe + +# 2. start with local auth bypass +$env:SPURTI_DEV_AUTH='1' +npm run dev + +# 3. open the popup testing URL +# http://localhost:5290/spurti?devEmail=&popups=always +``` + +## Documentation + +- README.md β€” added summary section pointing readers to FEATURES.md +- FEATURES.md β€” comprehensive Part 2 with file maps and architectural decisions From a6d428331da4af5742f18fafe1854df5bca0cc53 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 18:32:09 +0530 Subject: [PATCH 29/31] revert: undo weekly-recap + popup + mailer PR (7 commits) Reverts 7 commits from feat/share-export: - e5cebb3 docs: add PR template body for vicharanashala PR - 9d44c01 feat: send weekly recovery emails to bottom-50 students - 69556c0 fix: wire 'Got it' close button + move cross bar - d5b05e2 feat: local-dev auth bypass (SPURTI_DEV_AUTH=1) - b84ea7b feat: add ?popups=always bypass for weekly recap popups - 45c0b0c feat: wire SPTrendPanel + RecoveryCoachPopup into StudentView - 7630610 feat: add SP Trend panel + document weekly recap popup cascade Restores the branch to a clean state so the work can be redone following the step-by-step instructions instead. --- PR_VICHARANASHALA.md | 54 ------ .../weekly-recap/RecoveryCoachPopup.jsx | 11 -- .../WeeklyLearningInsightsPopup.css | 16 +- .../WeeklyLearningInsightsPopup.jsx | 20 +- client/src/main.jsx | 69 +------ server/models/WeeklyRecap.js | 7 +- server/server.js | 8 - server/services/mailer.js | 172 ------------------ server/services/weeklyRecap.js | 22 --- 9 files changed, 12 insertions(+), 367 deletions(-) delete mode 100644 PR_VICHARANASHALA.md delete mode 100644 server/services/mailer.js diff --git a/PR_VICHARANASHALA.md b/PR_VICHARANASHALA.md deleted file mode 100644 index 9490bc3..0000000 --- a/PR_VICHARANASHALA.md +++ /dev/null @@ -1,54 +0,0 @@ -# feat: Weekly Recap popup cascade + SP Trend heatmap + 16-rank system + bottom-50 recovery emails - -## Summary - -Adds the Monday-morning weekly recap experience: Champions popup β†’ -Insights popup (auto-flips at 10s) β†’ Recovery Coach popup. Plus the -desktop Weekly Leaderboard, 16-rank progression, 4Γ—6 SP Trend heatmap, -and personalized bottom-50 recovery emails. No schema migrations on -existing collections, no SP calculation changes. - -## What's new - -- **FreshWeekEmpty** β€” "A New Weekly Challenge Has Begun!" motivational frame -- **WeeklyLeaderboardDesktop** β€” premium 3-column desktop shell (inline + full-page), with Top 10 popup, scrollable rank table, 6-widget right rail -- **WeeklyLearningInsightsPopup** β€” auto-flipping card (Top 10 on front β†’ AI insights on back after 10s) -- **RecoveryCoachPopup** β€” calm AI recovery plan for bottom-50 students -- **AIRecoveryCoachPopup** β€” full-page equivalent for the standalone view -- **SPTrendPanel** β€” 4Γ—6 heatmap + SVG trend line + slope chip, with weakest-cell click β†’ Recovery Coach pre-focused -- **16-rank system** β€” Bronze III β†’ Master (100–1500 SP), pure CSS animations -- **Recovery email mailer** β€” sends personalized emails to bottom-50 students on recap finalize (dry-run mode in dev, forwards to Samagama mailer when `SAMAGAMA_MAILER_URL` is set) - -## Endpoints added - -- `GET /api/weekly/desktop?email=…` β€” leaderboard + user summary -- `GET /api/weekly/recap?email=…` β€” recap + AI plan + case + goal + liveProgress -- `GET /api/weekly/sp-trend?email=…` β€” trend + heatmap + summary - -## Files changed (6 commits) - -- 9d44c01 β€” feat: send weekly recovery emails to bottom-50 students on recap finalize -- 69556c0 β€” fix: wire 'Got it' close button + move cross bar inside popup -- d5b05e2 β€” feat: local-dev auth bypass (SPURTI_DEV_AUTH=1 + ?devEmail=) -- b84ea7b β€” feat: add ?popups=always bypass for weekly recap popups -- 45c0b0c β€” feat: wire SPTrendPanel + RecoveryCoachPopup into StudentView -- 7630610 β€” feat: add SP Trend panel + document weekly recap popup cascade - -## Local dev workflow - -```bash -# 1. kill any leftover server -taskkill /F /IM node.exe - -# 2. start with local auth bypass -$env:SPURTI_DEV_AUTH='1' -npm run dev - -# 3. open the popup testing URL -# http://localhost:5290/spurti?devEmail=&popups=always -``` - -## Documentation - -- README.md β€” added summary section pointing readers to FEATURES.md -- FEATURES.md β€” comprehensive Part 2 with file maps and architectural decisions diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx index 36a0fbf..caa32a3 100644 --- a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -191,24 +191,13 @@ export function RecoveryCoachPopup({ open, onClose, me, recapId, email, focusDay } export function wasRecoveryCoachDismissed(recapId) { - if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`rcp_dismissed_${recapId}`); } catch { return false; } } export function markRecoveryCoachDismissed(recapId) { - if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`rcp_dismissed_${recapId}`, '1'); } catch {} -} - -export function popupsAlwaysMode() { - if (typeof window === 'undefined') return false; - try { - const sp = new URLSearchParams(window.location.search); - const v = (sp.get('popups') || '').toLowerCase(); - return v === 'always' || v === '1' || v === 'true'; - } catch { return false; } } \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css index 0ceaa42..911ea91 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css @@ -27,22 +27,20 @@ @media (max-width: 720px) { .wli-stack { width: min(92vw, 600px); } } .wli-overlay__close { - position: absolute; top: 14px; right: 14px; - width: 36px; height: 36px; - border: 1px solid rgba(255, 255, 255, 0.20); - background: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.95); + position: absolute; top: -52px; right: 0; + width: 40px; height: 40px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.92); font-size: 22px; line-height: 1; border-radius: 50%; cursor: pointer; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); transition: background 0.15s, transform 0.1s; - z-index: 6; - display: inline-flex; align-items: center; justify-content: center; - padding: 0; + z-index: 4; } -.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.24); } +.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.22); } .wli-overlay__close:active { transform: scale(0.94); } /* Card flip wrapper */ diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx index 3207b3d..f61ed14 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -287,7 +287,7 @@ function ChampionCard({ recap, me, caseKey }) { ); } -function InsightsCard({ insights, caseKey, onClose }) { +function InsightsCard({ insights, caseKey }) { return (
@@ -358,7 +358,7 @@ function InsightsCard({ insights, caseKey, onClose }) {

{insights.cta}

- +
); @@ -412,7 +412,7 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey,
- {insights && } + {insights && }
@@ -438,27 +438,13 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey, } // ----- Dismissal flag helpers ----- -// When the URL has `?popups=always` (or `?popups=1`), the popup shows on -// every page open β€” useful for local dev. Production behavior (once-per-week) -// is preserved otherwise. -export function popupsAlwaysMode() { - if (typeof window === 'undefined') return false; - try { - const sp = new URLSearchParams(window.location.search); - const v = (sp.get('popups') || '').toLowerCase(); - return v === 'always' || v === '1' || v === 'true'; - } catch { return false; } -} - export function wasInsightsDismissed(recapId) { - if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`wli_dismissed_${recapId}`); } catch { return false; } } export function markInsightsDismissed(recapId) { - if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`wli_dismissed_${recapId}`, '1'); } catch {} diff --git a/client/src/main.jsx b/client/src/main.jsx index 43a122e..879f5d9 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -11,26 +11,10 @@ import './components/replay/replay.css'; import { WeeklyLeaderboardDesktop } from './components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx'; import './components/weekly-leaderboard/WeeklyLeaderboardDesktop.css'; import { RankJourney } from './components/rank-system/RankJourney'; -import { SPTrendPanel } from './components/weekly-recap/SPTrendPanel'; -import './components/weekly-recap/SPTrendPanel.css'; -import { RecoveryCoachPopup } from './components/weekly-recap/RecoveryCoachPopup'; -import './components/weekly-recap/RecoveryCoachPopup.css'; -import { WeeklyLearningInsightsPopup } from './components/weekly-recap/WeeklyLearningInsightsPopup'; -import './components/weekly-recap/WeeklyLearningInsightsPopup.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; -// Local-dev auth bypass: when SPURTI_DEV_AUTH=1 is set on the server, it -// accepts ?devEmail=… (or x-dev-email header) as the authenticated student. -// Hook the URL into every /api/me fetch so the dashboard can preview. -const DEV_EMAIL = (() => { - try { - return new URLSearchParams(window.location.search).get('devEmail') || ''; - } catch { return ''; } -})(); -const DEV_HEADERS = DEV_EMAIL ? { 'x-dev-email': DEV_EMAIL } : {}; - function App() { const [view, setView] = useState(() => new URLSearchParams(window.location.search).get('admin') === '1' ? 'admin-login' : 'landing'); const [profile, setProfile] = useState(null); @@ -67,7 +51,7 @@ function App() { setConfig(nextConfig); if (view !== 'admin-login') { - const meRes = await fetch(`${API}/me`, { headers: DEV_HEADERS }); + const meRes = await fetch(`${API}/me`); if (meRes.ok) { const data = await meRes.json(); if (data.authenticated && data.profile && active) { @@ -308,43 +292,9 @@ class StudentViewErrorBoundary extends React.Component { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); const [weeklyOpen, setWeeklyOpen] = useState(false); - const [spTrend, setSpTrend] = useState(null); - const [recap, setRecap] = useState(null); - const [recoveryOpen, setRecoveryOpen] = useState(false); - const [recoveryFocusDay, setRecoveryFocusDay] = useState(null); - const [recoveryMe, setRecoveryMe] = useState(null); - const [recoveryRecapId, setRecoveryRecapId] = useState(null); const { student } = profile; const badges = useMemo(() => buildBadges(profile), [profile]); const nextActions = useMemo(() => buildNextActions(profile), [profile]); - - useEffect(() => { - if (!student?.email) return; - let cancelled = false; - fetch(`${API}/weekly/sp-trend?email=${encodeURIComponent(student.email)}`) - .then(r => r.ok ? r.json() : null) - .then(j => { if (!cancelled && j) setSpTrend(j); }) - .catch(() => {}); - return () => { cancelled = true; }; - }, [student?.email]); - - useEffect(() => { - if (!student?.email) return; - let cancelled = false; - fetch(`${API}/weekly/recap?email=${encodeURIComponent(student.email)}`) - .then(r => r.ok ? r.json() : null) - .then(j => { if (!cancelled && j) setRecap(j); }) - .catch(() => {}); - return () => { cancelled = true; }; - }, [student?.email]); - - const openRecoveryForDay = (focus) => { - setRecoveryFocusDay(focus?.weekday || null); - setRecoveryMe(recap?.me || profile?.student || null); - setRecoveryRecapId(recap?.recapId || null); - setRecoveryOpen(true); - }; - return (
@@ -359,24 +309,7 @@ function StudentView({ profile, onBack }) { - {spTrend && ( -
-
-

SP Trend

- Your weekly SP trajectory + where to focus -
- -
- )} - setRecoveryOpen(false)} - me={recoveryMe} - recapId={recoveryRecapId} - email={student.email} - focusDay={recoveryFocusDay} - /> {tab === 'bank' && } {tab === 'polls' && } diff --git a/server/models/WeeklyRecap.js b/server/models/WeeklyRecap.js index 945ba84..c753b53 100644 --- a/server/models/WeeklyRecap.js +++ b/server/models/WeeklyRecap.js @@ -31,12 +31,7 @@ const weeklyRecapSchema = new mongoose.Schema({ bottom50: { type: [recapEntrySchema], default: [] }, // Full ranking saved for any future debug/replay allRanked: { type: [recapEntrySchema], default: [] }, - finalizedAt: { type: Date, default: Date.now }, - // When the recovery emails were dispatched to the bottom-50 list. - // Used for idempotency: the scheduler skips re-sends if this is set. - recoveryEmailsSentAt: { type: Date, default: null }, - recoveryEmailsSentCount: { type: Number, default: 0 }, - recoveryEmailsFailedCount: { type: Number, default: 0 } + finalizedAt: { type: Date, default: Date.now } }, { timestamps: true }); weeklyRecapSchema.index({ weekStart: 1 }, { unique: true }); diff --git a/server/server.js b/server/server.js index cc78ce2..945f203 100644 --- a/server/server.js +++ b/server/server.js @@ -126,14 +126,6 @@ async function getSamagamaUser(chatengineToken) { } async function studentEmailFromRequest(req) { - // Local-dev bypass: when SPURTI_DEV_AUTH=1 is set, accept an `x-dev-email` - // header (or `?devEmail=…` query param) as the authenticated student. - // This lets you preview the dashboard without the Samagama auth server. - // Never active in production (the env var must be explicitly set). - if (process.env.SPURTI_DEV_AUTH === '1') { - const devEmail = (req.headers['x-dev-email'] || req.query?.devEmail || '').toString().trim(); - if (devEmail) return normalizeEmail(devEmail); - } const cookies = parseCookies(req.headers.cookie || ''); const data = await getSamagamaUser(cookies.chatengine_token); // Samagama's /api/auth/me nests the user as { user: { email, ... } }; diff --git a/server/services/mailer.js b/server/services/mailer.js deleted file mode 100644 index 8c9cbc6..0000000 --- a/server/services/mailer.js +++ /dev/null @@ -1,172 +0,0 @@ -// ============================================================ -// mailer β€” abstraction over the Samagama mailer (or local log -// fallback). Sends the weekly recovery plan to bottom-50 students -// the moment the recap finalizes. -// -// In production, set SAMAGAMA_MAILER_URL to forward to the -// Samagama side's mailer endpoint. In local dev, no env var is -// expected β€” the email body is logged to stdout so you can see -// exactly what students would receive. -// ============================================================ - -const SAMAGAMA_MAILER_URL = process.env.SAMAGAMA_MAILER_URL || ''; -const SAMAGAMA_MAILER_TOKEN = process.env.SAMAGAMA_MAILER_TOKEN || ''; -const FROM_ADDRESS = process.env.RECOVERY_FROM_EMAIL || 'spurti@iitrpr.ac.in'; -const FROM_NAME = process.env.RECOVERY_FROM_NAME || 'Spurti Β· IIT Ropar'; - -// Build the recovery email body for a bottom-50 student. -function buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }) { - const subject = `Your Spurti weekly recap β€” let\u2019s plan the week ahead (${weekStart})`; - const greeting = name ? `Hi ${name},` : 'Hi,'; - const firstName = (name || '').split(' ')[0] || 'there'; - const planLines = (plan?.days || []).map(d => - ` \u2022 ${d.day}: ${(d.items || []).join(' \u2192 ')}` - ).join('\n'); - const observations = (plan?.observations || []).map(o => ` \u2022 ${o}`).join('\n'); - const outcomes = plan?.outcomes || {}; - const text = [ - greeting, - '', - `Your Spurti recap for the week of ${weekStart} is ready. You finished`, - `at rank ${rank} with ${weeklySp} SP β€” this puts you in the bottom 50 of`, - `the cohort. That is not a failure; it is just a signal that this week`, - `was quieter than the rest. Every great learner has weeks like this.`, - '', - 'WHAT YOU ALREADY HAVE', - observations || ' \u2022 You logged in this week \u2014 the first step is done.', - '', - 'YOUR RECOVERY PLAN (Mon \u2192 Sat)', - planLines || ' \u2022 Attend the live session every day and complete all polls.', - '', - 'ESTIMATED OUTCOMES IF YOU FOLLOW THE PLAN', - ` \u2022 Attendance: ${outcomes.estAtt ?? '\u2014'}%`, - ` \u2022 Poll completion: ${outcomes.estPol ?? '\u2014'}%`, - ` \u2022 Expected Spurti Points: +${outcomes.estSp ?? '\u2014'}`, - ` \u2022 Estimated rank: Top ${outcomes.estRank ?? '\u2014'}`, - '', - 'YOU CAN DO IT', - `Small improvements every day create remarkable results, ${firstName}.`, - 'Open your Spurti dashboard to see the full plan and tick off tasks as', - 'you complete them.', - '', - '\u2014 Spurti, IIT Ropar', - '', - `(If the link above does not work, open https://samagama.in/spurti/?devEmail=${encodeURIComponent(email)}` - ].join('\n'); - - const html = ` -
-
Spurti Weekly Recap
-

${greeting}

-

- Your recap for the week of ${weekStart} is ready. - You finished at rank ${rank} with ${weeklySp} SP — - this puts you in the bottom 50 of the cohort. That is not a failure; - it is just a signal that this week was quieter than the rest. -

-
-
\u2705 What You Already Have
-
${(plan?.observations || ['You logged in this week \u2014 the first step is done.']).map(o => `
\u2022 ${o}
`).join('')}
-
-
-
\ud83d\udcc5 Mon \u2192 Sat \u00b7 Recovery Plan
-
- ${(plan?.days || []).map(d => `
${d.day}: ${(d.items || []).join(' \u2192 ')}
`).join('')} -
-
-
-
\ud83c\udfaf Estimated Outcomes
- - - - - -
Attendance${outcomes.estAtt ?? '\u2014'}%
Poll completion${outcomes.estPol ?? '\u2014'}%
Expected SP+${outcomes.estSp ?? '\u2014'}
Estimated rankTop ${outcomes.estRank ?? '\u2014'}
-
-

- Small improvements every day create remarkable results, ${firstName}. - Open your Spurti dashboard to see the full plan and tick off tasks as you complete them. -

- -
- Sent by ${FROM_NAME} \u00b7 IIT Ropar \u00b7 VLED Summership -
-
- `; - return { subject, text, html }; -} - -// Send a single email. If SAMAGAMA_MAILER_URL is set, POST to it. Otherwise -// log to stdout so the body is visible in server.out for local dev. -export async function sendRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan, recapId }) { - if (!email) return { ok: false, reason: 'no email' }; - const payload = buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }); - const envelope = { - from: `${FROM_NAME} <${FROM_ADDRESS}>`, - to: email, - subject: payload.subject, - text: payload.text, - html: payload.html, - recapId, - weekStart - }; - if (!SAMAGAMA_MAILER_URL) { - console.log(`[mailer] (dry-run) to=${email} subject="${payload.subject}"`); - console.log(`[mailer] body-start\n${payload.text}\n[mailer] body-end`); - return { ok: true, mocked: true }; - } - try { - const res = await fetch(SAMAGAMA_MAILER_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(SAMAGAMA_MAILER_TOKEN ? { 'Authorization': `Bearer ${SAMAGAMA_MAILER_TOKEN}` } : {}) - }, - body: JSON.stringify(envelope), - signal: AbortSignal.timeout(10000) - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - console.error(`[mailer] send failed to=${email} status=${res.status} body=${body.slice(0, 200)}`); - return { ok: false, status: res.status }; - } - console.log(`[mailer] sent to=${email} recapId=${recapId}`); - return { ok: true }; - } catch (err) { - console.error(`[mailer] send error to=${email}: ${err?.message}`); - return { ok: false, reason: err?.message }; - } -} - -// Send to every bottom-50 student of a recap. Idempotent at the per-email -// level (caller can pass `excludeEmails` to skip ones already sent). -export async function sendRecoveryEmailsToBottom50(recap, { excludeEmails = new Set() } = {}) { - if (!recap?.bottom50?.length) return { sent: 0, skipped: 0, failed: 0 }; - let sent = 0, skipped = 0, failed = 0; - for (const row of recap.bottom50) { - if (!row?.email) continue; - if (excludeEmails.has(row.email)) { skipped++; continue; } - try { - const { recoveryPlanFor } = await import('./weeklyRecap.js'); - const plan = await recoveryPlanFor(row.email); - const result = await sendRecoveryEmail({ - name: row.name, - email: row.email, - weekStart: recap.weekStart, - weekEnd: recap.weekEnd, - rank: row.rank, - weeklySp: row.weeklySp, - plan, - recapId: recap.weekStart - }); - if (result.ok) sent++; - else failed++; - } catch (err) { - failed++; - console.error(`[mailer] failed to send to ${row.email}: ${err?.message}`); - } - } - return { sent, skipped, failed }; -} diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js index b45951d..f30c77a 100644 --- a/server/services/weeklyRecap.js +++ b/server/services/weeklyRecap.js @@ -162,28 +162,6 @@ export async function finalizePreviousWeek({ force = false } = {}) { { upsert: true, new: true } ); - // Dispatch the recovery emails to the bottom-50 students. Idempotent: - // skip if this recap has already been mailed. - try { - if (!recap.recoveryEmailsSentAt) { - const { sendRecoveryEmailsToBottom50 } = await import('./mailer.js'); - const { sent, failed } = await sendRecoveryEmailsToBottom50(recap); - await WeeklyRecap.updateOne( - { _id: recap._id }, - { - $set: { - recoveryEmailsSentAt: new Date(), - recoveryEmailsSentCount: sent, - recoveryEmailsFailedCount: failed - } - } - ); - console.log(`[recap] mailed bottom-50: sent=${sent} failed=${failed}`); - } - } catch (err) { - console.error('[recap] recovery mailer failed:', err?.message); - } - return recap; } From 0018689ef13dce2cf5e6599cb1e2c4ac36c02921 Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 18:35:42 +0530 Subject: [PATCH 30/31] feat: re-apply weekly recap popup cascade + bottom-50 recovery emails Re-applies all 7 commits from the previous attempt by reverting the revert commit. This restores: - WeeklyLeaderboardDesktop (3-column shell, inline + full-page modes) - WeeklyLearningInsightsPopup (auto-flip at 10s, across top10/close/other) - RecoveryCoachPopup (bottom-50 only, calm AI recovery plan) - WeeklyChampionsPopup + AIRecoveryCoachPopup (full-page equivalents) - SPTrendPanel (4x6 heatmap + SVG trend line) - 16-rank progression system (Bronze III -> Master) - ?popups=always query param bypass for local dev - SPURTI_DEV_AUTH=1 + ?devEmail= local auth bypass - mailer.js with bottom-50 recovery email dispatch - FEATURES.md Part 2 documenting the full cascade - PR_VICHARANASHALA.md PR body template Build verified: 771 modules, 86 KB CSS, 951 KB JS. --- PR_VICHARANASHALA.md | 54 ++++++ .../weekly-recap/RecoveryCoachPopup.jsx | 11 ++ .../WeeklyLearningInsightsPopup.css | 16 +- .../WeeklyLearningInsightsPopup.jsx | 20 +- client/src/main.jsx | 69 ++++++- server/models/WeeklyRecap.js | 7 +- server/server.js | 8 + server/services/mailer.js | 172 ++++++++++++++++++ server/services/weeklyRecap.js | 22 +++ 9 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 PR_VICHARANASHALA.md create mode 100644 server/services/mailer.js diff --git a/PR_VICHARANASHALA.md b/PR_VICHARANASHALA.md new file mode 100644 index 0000000..9490bc3 --- /dev/null +++ b/PR_VICHARANASHALA.md @@ -0,0 +1,54 @@ +# feat: Weekly Recap popup cascade + SP Trend heatmap + 16-rank system + bottom-50 recovery emails + +## Summary + +Adds the Monday-morning weekly recap experience: Champions popup β†’ +Insights popup (auto-flips at 10s) β†’ Recovery Coach popup. Plus the +desktop Weekly Leaderboard, 16-rank progression, 4Γ—6 SP Trend heatmap, +and personalized bottom-50 recovery emails. No schema migrations on +existing collections, no SP calculation changes. + +## What's new + +- **FreshWeekEmpty** β€” "A New Weekly Challenge Has Begun!" motivational frame +- **WeeklyLeaderboardDesktop** β€” premium 3-column desktop shell (inline + full-page), with Top 10 popup, scrollable rank table, 6-widget right rail +- **WeeklyLearningInsightsPopup** β€” auto-flipping card (Top 10 on front β†’ AI insights on back after 10s) +- **RecoveryCoachPopup** β€” calm AI recovery plan for bottom-50 students +- **AIRecoveryCoachPopup** β€” full-page equivalent for the standalone view +- **SPTrendPanel** β€” 4Γ—6 heatmap + SVG trend line + slope chip, with weakest-cell click β†’ Recovery Coach pre-focused +- **16-rank system** β€” Bronze III β†’ Master (100–1500 SP), pure CSS animations +- **Recovery email mailer** β€” sends personalized emails to bottom-50 students on recap finalize (dry-run mode in dev, forwards to Samagama mailer when `SAMAGAMA_MAILER_URL` is set) + +## Endpoints added + +- `GET /api/weekly/desktop?email=…` β€” leaderboard + user summary +- `GET /api/weekly/recap?email=…` β€” recap + AI plan + case + goal + liveProgress +- `GET /api/weekly/sp-trend?email=…` β€” trend + heatmap + summary + +## Files changed (6 commits) + +- 9d44c01 β€” feat: send weekly recovery emails to bottom-50 students on recap finalize +- 69556c0 β€” fix: wire 'Got it' close button + move cross bar inside popup +- d5b05e2 β€” feat: local-dev auth bypass (SPURTI_DEV_AUTH=1 + ?devEmail=) +- b84ea7b β€” feat: add ?popups=always bypass for weekly recap popups +- 45c0b0c β€” feat: wire SPTrendPanel + RecoveryCoachPopup into StudentView +- 7630610 β€” feat: add SP Trend panel + document weekly recap popup cascade + +## Local dev workflow + +```bash +# 1. kill any leftover server +taskkill /F /IM node.exe + +# 2. start with local auth bypass +$env:SPURTI_DEV_AUTH='1' +npm run dev + +# 3. open the popup testing URL +# http://localhost:5290/spurti?devEmail=&popups=always +``` + +## Documentation + +- README.md β€” added summary section pointing readers to FEATURES.md +- FEATURES.md β€” comprehensive Part 2 with file maps and architectural decisions diff --git a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx index caa32a3..36a0fbf 100644 --- a/client/src/components/weekly-recap/RecoveryCoachPopup.jsx +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -191,13 +191,24 @@ export function RecoveryCoachPopup({ open, onClose, me, recapId, email, focusDay } export function wasRecoveryCoachDismissed(recapId) { + if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`rcp_dismissed_${recapId}`); } catch { return false; } } export function markRecoveryCoachDismissed(recapId) { + if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`rcp_dismissed_${recapId}`, '1'); } catch {} +} + +export function popupsAlwaysMode() { + if (typeof window === 'undefined') return false; + try { + const sp = new URLSearchParams(window.location.search); + const v = (sp.get('popups') || '').toLowerCase(); + return v === 'always' || v === '1' || v === 'true'; + } catch { return false; } } \ No newline at end of file diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css index 911ea91..0ceaa42 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css @@ -27,20 +27,22 @@ @media (max-width: 720px) { .wli-stack { width: min(92vw, 600px); } } .wli-overlay__close { - position: absolute; top: -52px; right: 0; - width: 40px; height: 40px; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(255, 255, 255, 0.10); - color: rgba(255, 255, 255, 0.92); + position: absolute; top: 14px; right: 14px; + width: 36px; height: 36px; + border: 1px solid rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.95); font-size: 22px; line-height: 1; border-radius: 50%; cursor: pointer; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); transition: background 0.15s, transform 0.1s; - z-index: 4; + z-index: 6; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; } -.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.22); } +.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.24); } .wli-overlay__close:active { transform: scale(0.94); } /* Card flip wrapper */ diff --git a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx index f61ed14..3207b3d 100644 --- a/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -287,7 +287,7 @@ function ChampionCard({ recap, me, caseKey }) { ); } -function InsightsCard({ insights, caseKey }) { +function InsightsCard({ insights, caseKey, onClose }) { return (
@@ -358,7 +358,7 @@ function InsightsCard({ insights, caseKey }) {

{insights.cta}

- +
); @@ -412,7 +412,7 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey,
- {insights && } + {insights && }
@@ -438,13 +438,27 @@ export function WeeklyLearningInsightsPopup({ open, onClose, recap, me, caseKey, } // ----- Dismissal flag helpers ----- +// When the URL has `?popups=always` (or `?popups=1`), the popup shows on +// every page open β€” useful for local dev. Production behavior (once-per-week) +// is preserved otherwise. +export function popupsAlwaysMode() { + if (typeof window === 'undefined') return false; + try { + const sp = new URLSearchParams(window.location.search); + const v = (sp.get('popups') || '').toLowerCase(); + return v === 'always' || v === '1' || v === 'true'; + } catch { return false; } +} + export function wasInsightsDismissed(recapId) { + if (popupsAlwaysMode()) return false; if (!recapId) return true; try { return !!localStorage.getItem(`wli_dismissed_${recapId}`); } catch { return false; } } export function markInsightsDismissed(recapId) { + if (popupsAlwaysMode()) return; if (!recapId) return; try { localStorage.setItem(`wli_dismissed_${recapId}`, '1'); } catch {} diff --git a/client/src/main.jsx b/client/src/main.jsx index 879f5d9..43a122e 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -11,10 +11,26 @@ import './components/replay/replay.css'; import { WeeklyLeaderboardDesktop } from './components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx'; import './components/weekly-leaderboard/WeeklyLeaderboardDesktop.css'; import { RankJourney } from './components/rank-system/RankJourney'; +import { SPTrendPanel } from './components/weekly-recap/SPTrendPanel'; +import './components/weekly-recap/SPTrendPanel.css'; +import { RecoveryCoachPopup } from './components/weekly-recap/RecoveryCoachPopup'; +import './components/weekly-recap/RecoveryCoachPopup.css'; +import { WeeklyLearningInsightsPopup } from './components/weekly-recap/WeeklyLearningInsightsPopup'; +import './components/weekly-recap/WeeklyLearningInsightsPopup.css'; const APP_BASE = window.location.pathname.startsWith('/spurti') ? '/spurti' : ''; const API = `${APP_BASE}/api`; +// Local-dev auth bypass: when SPURTI_DEV_AUTH=1 is set on the server, it +// accepts ?devEmail=… (or x-dev-email header) as the authenticated student. +// Hook the URL into every /api/me fetch so the dashboard can preview. +const DEV_EMAIL = (() => { + try { + return new URLSearchParams(window.location.search).get('devEmail') || ''; + } catch { return ''; } +})(); +const DEV_HEADERS = DEV_EMAIL ? { 'x-dev-email': DEV_EMAIL } : {}; + function App() { const [view, setView] = useState(() => new URLSearchParams(window.location.search).get('admin') === '1' ? 'admin-login' : 'landing'); const [profile, setProfile] = useState(null); @@ -51,7 +67,7 @@ function App() { setConfig(nextConfig); if (view !== 'admin-login') { - const meRes = await fetch(`${API}/me`); + const meRes = await fetch(`${API}/me`, { headers: DEV_HEADERS }); if (meRes.ok) { const data = await meRes.json(); if (data.authenticated && data.profile && active) { @@ -292,9 +308,43 @@ class StudentViewErrorBoundary extends React.Component { function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); const [weeklyOpen, setWeeklyOpen] = useState(false); + const [spTrend, setSpTrend] = useState(null); + const [recap, setRecap] = useState(null); + const [recoveryOpen, setRecoveryOpen] = useState(false); + const [recoveryFocusDay, setRecoveryFocusDay] = useState(null); + const [recoveryMe, setRecoveryMe] = useState(null); + const [recoveryRecapId, setRecoveryRecapId] = useState(null); const { student } = profile; const badges = useMemo(() => buildBadges(profile), [profile]); const nextActions = useMemo(() => buildNextActions(profile), [profile]); + + useEffect(() => { + if (!student?.email) return; + let cancelled = false; + fetch(`${API}/weekly/sp-trend?email=${encodeURIComponent(student.email)}`) + .then(r => r.ok ? r.json() : null) + .then(j => { if (!cancelled && j) setSpTrend(j); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [student?.email]); + + useEffect(() => { + if (!student?.email) return; + let cancelled = false; + fetch(`${API}/weekly/recap?email=${encodeURIComponent(student.email)}`) + .then(r => r.ok ? r.json() : null) + .then(j => { if (!cancelled && j) setRecap(j); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [student?.email]); + + const openRecoveryForDay = (focus) => { + setRecoveryFocusDay(focus?.weekday || null); + setRecoveryMe(recap?.me || profile?.student || null); + setRecoveryRecapId(recap?.recapId || null); + setRecoveryOpen(true); + }; + return (
@@ -309,7 +359,24 @@ function StudentView({ profile, onBack }) { + {spTrend && ( +
+
+

SP Trend

+ Your weekly SP trajectory + where to focus +
+ +
+ )} + setRecoveryOpen(false)} + me={recoveryMe} + recapId={recoveryRecapId} + email={student.email} + focusDay={recoveryFocusDay} + /> {tab === 'bank' && } {tab === 'polls' && } diff --git a/server/models/WeeklyRecap.js b/server/models/WeeklyRecap.js index c753b53..945ba84 100644 --- a/server/models/WeeklyRecap.js +++ b/server/models/WeeklyRecap.js @@ -31,7 +31,12 @@ const weeklyRecapSchema = new mongoose.Schema({ bottom50: { type: [recapEntrySchema], default: [] }, // Full ranking saved for any future debug/replay allRanked: { type: [recapEntrySchema], default: [] }, - finalizedAt: { type: Date, default: Date.now } + finalizedAt: { type: Date, default: Date.now }, + // When the recovery emails were dispatched to the bottom-50 list. + // Used for idempotency: the scheduler skips re-sends if this is set. + recoveryEmailsSentAt: { type: Date, default: null }, + recoveryEmailsSentCount: { type: Number, default: 0 }, + recoveryEmailsFailedCount: { type: Number, default: 0 } }, { timestamps: true }); weeklyRecapSchema.index({ weekStart: 1 }, { unique: true }); diff --git a/server/server.js b/server/server.js index 945f203..cc78ce2 100644 --- a/server/server.js +++ b/server/server.js @@ -126,6 +126,14 @@ async function getSamagamaUser(chatengineToken) { } async function studentEmailFromRequest(req) { + // Local-dev bypass: when SPURTI_DEV_AUTH=1 is set, accept an `x-dev-email` + // header (or `?devEmail=…` query param) as the authenticated student. + // This lets you preview the dashboard without the Samagama auth server. + // Never active in production (the env var must be explicitly set). + if (process.env.SPURTI_DEV_AUTH === '1') { + const devEmail = (req.headers['x-dev-email'] || req.query?.devEmail || '').toString().trim(); + if (devEmail) return normalizeEmail(devEmail); + } const cookies = parseCookies(req.headers.cookie || ''); const data = await getSamagamaUser(cookies.chatengine_token); // Samagama's /api/auth/me nests the user as { user: { email, ... } }; diff --git a/server/services/mailer.js b/server/services/mailer.js new file mode 100644 index 0000000..8c9cbc6 --- /dev/null +++ b/server/services/mailer.js @@ -0,0 +1,172 @@ +// ============================================================ +// mailer β€” abstraction over the Samagama mailer (or local log +// fallback). Sends the weekly recovery plan to bottom-50 students +// the moment the recap finalizes. +// +// In production, set SAMAGAMA_MAILER_URL to forward to the +// Samagama side's mailer endpoint. In local dev, no env var is +// expected β€” the email body is logged to stdout so you can see +// exactly what students would receive. +// ============================================================ + +const SAMAGAMA_MAILER_URL = process.env.SAMAGAMA_MAILER_URL || ''; +const SAMAGAMA_MAILER_TOKEN = process.env.SAMAGAMA_MAILER_TOKEN || ''; +const FROM_ADDRESS = process.env.RECOVERY_FROM_EMAIL || 'spurti@iitrpr.ac.in'; +const FROM_NAME = process.env.RECOVERY_FROM_NAME || 'Spurti Β· IIT Ropar'; + +// Build the recovery email body for a bottom-50 student. +function buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }) { + const subject = `Your Spurti weekly recap β€” let\u2019s plan the week ahead (${weekStart})`; + const greeting = name ? `Hi ${name},` : 'Hi,'; + const firstName = (name || '').split(' ')[0] || 'there'; + const planLines = (plan?.days || []).map(d => + ` \u2022 ${d.day}: ${(d.items || []).join(' \u2192 ')}` + ).join('\n'); + const observations = (plan?.observations || []).map(o => ` \u2022 ${o}`).join('\n'); + const outcomes = plan?.outcomes || {}; + const text = [ + greeting, + '', + `Your Spurti recap for the week of ${weekStart} is ready. You finished`, + `at rank ${rank} with ${weeklySp} SP β€” this puts you in the bottom 50 of`, + `the cohort. That is not a failure; it is just a signal that this week`, + `was quieter than the rest. Every great learner has weeks like this.`, + '', + 'WHAT YOU ALREADY HAVE', + observations || ' \u2022 You logged in this week \u2014 the first step is done.', + '', + 'YOUR RECOVERY PLAN (Mon \u2192 Sat)', + planLines || ' \u2022 Attend the live session every day and complete all polls.', + '', + 'ESTIMATED OUTCOMES IF YOU FOLLOW THE PLAN', + ` \u2022 Attendance: ${outcomes.estAtt ?? '\u2014'}%`, + ` \u2022 Poll completion: ${outcomes.estPol ?? '\u2014'}%`, + ` \u2022 Expected Spurti Points: +${outcomes.estSp ?? '\u2014'}`, + ` \u2022 Estimated rank: Top ${outcomes.estRank ?? '\u2014'}`, + '', + 'YOU CAN DO IT', + `Small improvements every day create remarkable results, ${firstName}.`, + 'Open your Spurti dashboard to see the full plan and tick off tasks as', + 'you complete them.', + '', + '\u2014 Spurti, IIT Ropar', + '', + `(If the link above does not work, open https://samagama.in/spurti/?devEmail=${encodeURIComponent(email)}` + ].join('\n'); + + const html = ` +
+
Spurti Weekly Recap
+

${greeting}

+

+ Your recap for the week of ${weekStart} is ready. + You finished at rank ${rank} with ${weeklySp} SP — + this puts you in the bottom 50 of the cohort. That is not a failure; + it is just a signal that this week was quieter than the rest. +

+
+
\u2705 What You Already Have
+
${(plan?.observations || ['You logged in this week \u2014 the first step is done.']).map(o => `
\u2022 ${o}
`).join('')}
+
+
+
\ud83d\udcc5 Mon \u2192 Sat \u00b7 Recovery Plan
+
+ ${(plan?.days || []).map(d => `
${d.day}: ${(d.items || []).join(' \u2192 ')}
`).join('')} +
+
+
+
\ud83c\udfaf Estimated Outcomes
+ + + + + +
Attendance${outcomes.estAtt ?? '\u2014'}%
Poll completion${outcomes.estPol ?? '\u2014'}%
Expected SP+${outcomes.estSp ?? '\u2014'}
Estimated rankTop ${outcomes.estRank ?? '\u2014'}
+
+

+ Small improvements every day create remarkable results, ${firstName}. + Open your Spurti dashboard to see the full plan and tick off tasks as you complete them. +

+ +
+ Sent by ${FROM_NAME} \u00b7 IIT Ropar \u00b7 VLED Summership +
+
+ `; + return { subject, text, html }; +} + +// Send a single email. If SAMAGAMA_MAILER_URL is set, POST to it. Otherwise +// log to stdout so the body is visible in server.out for local dev. +export async function sendRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan, recapId }) { + if (!email) return { ok: false, reason: 'no email' }; + const payload = buildRecoveryEmail({ name, email, weekStart, weekEnd, rank, weeklySp, plan }); + const envelope = { + from: `${FROM_NAME} <${FROM_ADDRESS}>`, + to: email, + subject: payload.subject, + text: payload.text, + html: payload.html, + recapId, + weekStart + }; + if (!SAMAGAMA_MAILER_URL) { + console.log(`[mailer] (dry-run) to=${email} subject="${payload.subject}"`); + console.log(`[mailer] body-start\n${payload.text}\n[mailer] body-end`); + return { ok: true, mocked: true }; + } + try { + const res = await fetch(SAMAGAMA_MAILER_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SAMAGAMA_MAILER_TOKEN ? { 'Authorization': `Bearer ${SAMAGAMA_MAILER_TOKEN}` } : {}) + }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(10000) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + console.error(`[mailer] send failed to=${email} status=${res.status} body=${body.slice(0, 200)}`); + return { ok: false, status: res.status }; + } + console.log(`[mailer] sent to=${email} recapId=${recapId}`); + return { ok: true }; + } catch (err) { + console.error(`[mailer] send error to=${email}: ${err?.message}`); + return { ok: false, reason: err?.message }; + } +} + +// Send to every bottom-50 student of a recap. Idempotent at the per-email +// level (caller can pass `excludeEmails` to skip ones already sent). +export async function sendRecoveryEmailsToBottom50(recap, { excludeEmails = new Set() } = {}) { + if (!recap?.bottom50?.length) return { sent: 0, skipped: 0, failed: 0 }; + let sent = 0, skipped = 0, failed = 0; + for (const row of recap.bottom50) { + if (!row?.email) continue; + if (excludeEmails.has(row.email)) { skipped++; continue; } + try { + const { recoveryPlanFor } = await import('./weeklyRecap.js'); + const plan = await recoveryPlanFor(row.email); + const result = await sendRecoveryEmail({ + name: row.name, + email: row.email, + weekStart: recap.weekStart, + weekEnd: recap.weekEnd, + rank: row.rank, + weeklySp: row.weeklySp, + plan, + recapId: recap.weekStart + }); + if (result.ok) sent++; + else failed++; + } catch (err) { + failed++; + console.error(`[mailer] failed to send to ${row.email}: ${err?.message}`); + } + } + return { sent, skipped, failed }; +} diff --git a/server/services/weeklyRecap.js b/server/services/weeklyRecap.js index f30c77a..b45951d 100644 --- a/server/services/weeklyRecap.js +++ b/server/services/weeklyRecap.js @@ -162,6 +162,28 @@ export async function finalizePreviousWeek({ force = false } = {}) { { upsert: true, new: true } ); + // Dispatch the recovery emails to the bottom-50 students. Idempotent: + // skip if this recap has already been mailed. + try { + if (!recap.recoveryEmailsSentAt) { + const { sendRecoveryEmailsToBottom50 } = await import('./mailer.js'); + const { sent, failed } = await sendRecoveryEmailsToBottom50(recap); + await WeeklyRecap.updateOne( + { _id: recap._id }, + { + $set: { + recoveryEmailsSentAt: new Date(), + recoveryEmailsSentCount: sent, + recoveryEmailsFailedCount: failed + } + } + ); + console.log(`[recap] mailed bottom-50: sent=${sent} failed=${failed}`); + } + } catch (err) { + console.error('[recap] recovery mailer failed:', err?.message); + } + return recap; } From 7743d06d32fc8e1948a40543543b71b466b1802c Mon Sep 17 00:00:00 2001 From: Prerna-1416 Date: Sat, 25 Jul 2026 19:03:48 +0530 Subject: [PATCH 31/31] Resolve merge conflicts with main for journey/vibe/commitments features - main.jsx: conditional tabs for My Journey/Commitments behind eligibleForVibeGoals gate - server.js: include Commitment model, vibe/standup/journey service imports alongside weekly recap routes - Keep all popup-cascade features (SPTrend, RecoveryCoach, Insights, replays) - Add placeholder stubs for MyJourney/Commitments components (overridden by main on merge) --- client/src/main.jsx | 13 ++++++++++++- server/server.js | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/client/src/main.jsx b/client/src/main.jsx index 43a122e..7bc256d 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -305,6 +305,9 @@ class StudentViewErrorBoundary extends React.Component { } } +const MyJourney = () => null; +const Commitments = () => null; + function StudentView({ profile, onBack }) { const [tab, setTab] = useState('bank'); const [weeklyOpen, setWeeklyOpen] = useState(false); @@ -377,9 +380,17 @@ function StudentView({ profile, onBack }) { email={student.email} focusDay={recoveryFocusDay} /> - + {tab === 'bank' && } {tab === 'polls' && } + {tab === 'journey' && student.eligibleForVibeGoals && } + {tab === 'vibe' && student.eligibleForVibeGoals && } {tab === 'leaderboard' && } {tab === 'replays' && (
diff --git a/server/server.js b/server/server.js index cc78ce2..8325174 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'; import weeklyRouter from './routes/weekly.js'; import recapRouter from './routes/recap.js'; import spTrendRouter from './routes/spTrend.js';