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/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/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/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 9d7a24f..3bdbe07 100644 --- a/client/package.json +++ b/client/package.json @@ -9,9 +9,11 @@ }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", - "vite": "^5.4.11", + "framer-motion": "^11.15.0", + "html2canvas": "^1.4.1", + "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/rank-system/RankJourney.jsx b/client/src/components/rank-system/RankJourney.jsx new file mode 100644 index 0000000..d12d6b0 --- /dev/null +++ b/client/src/components/rank-system/RankJourney.jsx @@ -0,0 +1,313 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { RANKS, TIER_THEME, RANK_DESCRIPTIONS, rankFor, nextRank } from './ranks'; +import './rank-system.css'; + +// ============================================================ +// 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. +// ============================================================ + +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 nextTier = next ? TIER_THEME[next.rank.tier] : null; + const desc = RANK_DESCRIPTIONS[rank.name] || ''; + return ( +
+
+ +
+
+
{tier.label.toUpperCase()} TIER
+
{rank.name}
+
{desc}
+ {next && nextTier && ( +
+ 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 tier = TIER_THEME[rank.tier]; + const nextTier = next ? TIER_THEME[next.rank.tier] : null; + const progressPct = spToPct(sp); + + return ( +
+
+
+
SP JOURNEY
+
+ {sp} + SP +
+
+
+
+
+ {rank.name} +
+
+ Tier {tier.label} · Rank {rank.idx}/16 +
+
+
+
+ +
+
+
+
+ {RANKS.map(r => ( + = r.min} + theme={TIER_THEME[r.tier]} + /> + ))} +
+ +
+
+ +
+
+
SP
+ 100 +
+ {next && nextTier ? ( +
+
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 ( +
+ + +
+ ); +} \ 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/components/replay/EntryPill.tsx b/client/src/components/replay/EntryPill.tsx new file mode 100644 index 0000000..e8a47f8 --- /dev/null +++ b/client/src/components/replay/EntryPill.tsx @@ -0,0 +1,54 @@ +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 ( + + + 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/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/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..af5f62e --- /dev/null +++ b/client/src/components/replay/WeeklyReplayModal.tsx @@ -0,0 +1,95 @@ +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 []; + 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); + 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 && ( +
+ + + +
+ )} +
+ )} +
+ {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 new file mode 100644 index 0000000..dbd2110 --- /dev/null +++ b/client/src/components/replay/replay.css @@ -0,0 +1,345 @@ +/* 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--leaderboard { background: linear-gradient(135deg, #4F46E5 0%, #06B6D4 100%); border: 1px solid rgba(255,255,255,0.6); box-shadow: 0 6px 18px rgba(79,70,229,0.45); } +.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; } + +/* ============================================================ + 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); } +} + +/* ============================================================ + Weekly Leaderboard overlay (opened from ReplaySection) + ============================================================ */ +.wl-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + overflow-y: auto; + animation: wl-overlay-in 0.25s ease-out; +} +@keyframes wl-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} +.wl-overlay__close { + position: fixed; top: 18px; right: 22px; + width: 38px; height: 38px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(15, 23, 42, 0.85); + color: #fff; + font-size: 22px; line-height: 1; + border-radius: 50%; + cursor: pointer; + z-index: 1010; + transition: background 0.15s, transform 0.1s; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); +} +.wl-overlay__close:hover { background: rgba(99, 102, 241, 0.85); } +.wl-overlay__close:active { transform: scale(0.94); } \ 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/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..38bfeb9 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboard.tsx @@ -0,0 +1,182 @@ +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) + 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 }) { + 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 + const listRef = useRef(null); + + const allRows = useMemo(() => { + if (!data) return []; + 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 }); + 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; + + 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 +
+
+ +
+ + + + + + + + + + + {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 +
+
+ ); +} diff --git a/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css new file mode 100644 index 0000000..579d95c --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.css @@ -0,0 +1,1385 @@ +/* ============================================================ + 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; +} + +/* 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; + 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); } +} + +/* ============================================================ + 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..28a59a4 --- /dev/null +++ b/client/src/components/weekly-leaderboard/WeeklyLeaderboardDesktop.tsx @@ -0,0 +1,342 @@ +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 { FreshWeekEmpty } from './FreshWeekEmpty'; +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 +// 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, inline = false }) { + const [theme, setTheme] = useState('dark'); + 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; + 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]); + + // 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 — 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' && ( + + )} + {data?.bucket === 'regular' && 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} + + { markInsightsDismissed(recap?.recapId); recapOpen.closeInsights(); }} + recap={recap?.recap} + me={recap?.me} + caseKey={recap?.case} + recapId={recap?.recapId} + email={email} + /> + { markRecoveryCoachDismissed(recap?.recapId); recapOpen.closeRecovery(); }} + me={recap?.me} + recapId={recap?.recapId} + email={email} + /> +
+ ); + } + + return ( +
+ setTheme(t => t === 'dark' ? 'light' : 'dark')} /> +
+ setTheme(t => t === 'dark' ? 'light' : 'dark')} profile={profile} /> + {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. 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 [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 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 (!insightsDismissed) setShowInsights(true); + else if (!recoveryDismissed && isBottom50) setShowRecovery(true); + }, 600); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recap?.recapId, recap?.case, email]); + + return { + 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); + } + }, + closeRecovery: () => setShowRecovery(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/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/RecoveryCoachPopup.css b/client/src/components/weekly-recap/RecoveryCoachPopup.css new file mode 100644 index 0000000..16bfd22 --- /dev/null +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.css @@ -0,0 +1,243 @@ +/* ============================================================ + 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); + 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; + 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..36a0fbf --- /dev/null +++ b/client/src/components/weekly-recap/RecoveryCoachPopup.jsx @@ -0,0 +1,214 @@ +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 +// 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 = [ + { 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, 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); + 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 => ( +
{ dayRefs.current[d.day] = el; }} + > +
{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 (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/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/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/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..463fb49 --- /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}

+

{subhead}

+
+ +
+
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/WeeklyLearningInsightsPopup.css b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css new file mode 100644 index 0000000..0ceaa42 --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.css @@ -0,0 +1,379 @@ +/* ============================================================ + 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: 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: 6; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; +} +.wli-overlay__close:hover { background: rgba(255, 255, 255, 0.24); } +.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..3207b3d --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyLearningInsightsPopup.jsx @@ -0,0 +1,465 @@ +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, onClose }) { + 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 ----- +// 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 {} +} \ 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..e881b56 --- /dev/null +++ b/client/src/components/weekly-recap/WeeklyRecap.css @@ -0,0 +1,486 @@ +/* ============================================================ + 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; } +} + +/* ===== 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/client/src/main.jsx b/client/src/main.jsx index 3a38e6c..7d2b866 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -1,10 +1,36 @@ 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'; +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); @@ -41,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) { @@ -262,13 +288,76 @@ 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); + 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 ( +
+
{onBack ? : }
@@ -277,17 +366,63 @@ function StudentView({ profile, onBack }) {
SP{student.totalSp}Rank {student.rank} of {student.cohortSize}
- + - +
+

SP Trend

+ Your weekly SP trajectory + where to focus +
+ + + )} + + setRecoveryOpen(false)} + me={recoveryMe} + recapId={recoveryRecapId} + email={student.email} + focusDay={recoveryFocusDay} + /> + + ['leaderboard','Leaderboard'], + ['replays','Replays'] + ]} /> {tab === 'bank' && } {tab === 'polls' && } {tab === 'journey' && student.eligibleForVibeGoals && } {tab === 'vibe' && student.eligibleForVibeGoals && } {tab === 'leaderboard' && } + {tab === 'replays' && ( +
+

📼 Replay History

+

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

+
+ {buildReplayHistory(profile, 6).map((w, i) => ( + + ))} +
+
+ )}
+
); } @@ -1281,5 +1416,37 @@ function SurveyModal({ survey, student, onDone, statusPath = '/survey/status', c ); } +function ReplaySection({ profile }) { + const [weeklyOpen, setWeeklyOpen] = useState(false); + const [finalOpen, setFinalOpen] = useState(false); + const [share, setShare] = useState(null); + const [unlocked, setUnlocked] = useState(false); + useEffect(() => { + setUnlocked(isFinalJourneyUnlocked(profile || {})); + }, [profile]); + if (!profile || !profile.student) return null; + return ( + <> +
+ setWeeklyOpen(true)} /> + {unlocked && ( + + setFinalOpen(true)} /> + + )} +
+ setWeeklyOpen(false)} profile={profile} onOpenShare={() => setShare({ kind: 'weekly' })} /> + setFinalOpen(false)} profile={profile} studentName={profile.student.name} onOpenShare={() => setShare({ kind: 'final' })} /> + {share && ( + setShare(null)} + /> + )} + + ); +} createRoot(document.getElementById('root')).render(); diff --git a/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 bda2b1e..3d1392e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,6 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^4.19.2", - "mongoose": "^8.8.4" + "mongoose": "^8.24.1" } } diff --git a/server/models/WeeklyRecap.js b/server/models/WeeklyRecap.js new file mode 100644 index 0000000..945ba84 --- /dev/null +++ b/server/models/WeeklyRecap.js @@ -0,0 +1,44 @@ +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 }, + // 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 }); + +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..f45ac95 --- /dev/null +++ b/server/routes/recap.js @@ -0,0 +1,91 @@ +import express from 'express'; +import { latestRecap, recoveryPlanFor } from '../services/weeklyRecap.js'; + +const router = express.Router(); + +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase(); +} + +// ============================================================ +// 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) +// ============================================================ +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: +// - recap : last week's Top 10 + Bottom 50 +// - plan : AI Recovery plan (only for bottom-50 students) +// - 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) => { + 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, + goal: null, + me: null, + case: null, + newWeek: null, + recapId: null, + message: 'No recap yet — the first recap is generated after the first week ends.' + }); + } + // 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, + 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, + me, + case: deriveCase(me, recap), + recapId: recap.weekStart, + newWeek: { weekStart: recap.weekStart } + }); +}); + +export default router; \ 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/routes/weekly.js b/server/routes/weekly.js new file mode 100644 index 0000000..704e13c --- /dev/null +++ b/server/routes/weekly.js @@ -0,0 +1,65 @@ +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 + })); + + const middle = agg.rows + .filter(r => r.weeklyRank > 10) + .map(r => ({ rank: r.weeklyRank, name: r.name, weeklySp: r.weeklySp, isMe: r.email === email })); + + 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 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 + }); +}); + +export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index 737b9f7..07754ec 100644 --- a/server/server.js +++ b/server/server.js @@ -17,6 +17,10 @@ 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'; +import { startWeeklyRecapScheduler } from './services/weeklyRecapScheduler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); @@ -148,6 +152,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, ... } }; @@ -738,6 +750,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', spTrendRouter); + app.use('/spurti/api/weekly', spTrendRouter); if (fs.existsSync(clientDist)) { app.use('/spurti', express.static(clientDist)); @@ -749,6 +767,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/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 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/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 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/weeklyRecap.js b/server/services/weeklyRecap.js new file mode 100644 index 0000000..b45951d --- /dev/null +++ b/server/services/weeklyRecap.js @@ -0,0 +1,239 @@ +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 } + ); + + // 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; +} + +// 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 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