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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,15 @@ which hold the retired CSV/±5 logic). See `pipeline/README.md` for detail.

- **Initial:** +100 to every *started intern* on their official start date.
Future-start interns are zeroed; non-intern roster entries are set aside.
- **Attendance (A):** presence clipped to the official window
`[09:05 IST, min(first-instance-end, 11:00 IST)]`; `pct = clipped / window`,
then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**.
- **Attendance (A):** presence clipped to the official window, `pct = clipped /
window`, then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**.
- **Before 2026-07-16 (morning standup):** window `[09:05 IST, min(first-instance-end, 11:00 IST)]`.
- **From 2026-07-16 (standup moved to evening):** window `[20:05 IST, min(picked-mtg-end, 21:00 IST)]`
and the scored meeting is the mandatory meeting with the **largest overlap**
of that evening window (not just the earliest-starting one — the all-day
persistent room must not steal the slot). Cutover + times are constants at
the top of `sp-rubric-build-mirror.cjs`: `EVENING_CUTOVER`,
`EVENING_WSTART_IST`, `EVENING_WEND_IST`. Change these if the timing shifts again.
- **Poll (B):** `pct = answered / totalQuestions`, same band ladder (10/5/3/0).
- **Grace day 2026-06-06:** 1-min join = full attendance + full poll.
- **Chat / discretionary:** admin-reviewed via ChatSPReview in the web app
Expand Down Expand Up @@ -168,6 +174,19 @@ Code: `getSamagamaUser` / `studentEmailFromRequest` in `server/server.js`.
- **To verify new ingestion:** After running `ingestSession`, check that: (a) new session appears in `sessions` collection, (b) transaction count increases, (c) for a sample student, balance in `sptransactions` matches their `totalSp` in `students` table, (d) leaderboard API reflects updated SP

## Known Bugs / Notes
- **2026-07-16 standup moved morning → evening (attendance window fix).** Students
flagged that the 16 Jul evening standup (~60 min) credited "115 min". Cause was
NOT double-counting: the scorer clipped presence to the fixed **09:05–11:00 IST
(=115 min) morning window**, which no longer matched the standup. The persistent
Zoom room `95674128668` ("Evening Standup") stays open all day, so it satisfied
the old morning window. Fix: added an evening-window cutover (see SP Calculation
section) → from 16 Jul the window is **20:05–21:00 IST (55 min)** and the scorer
picks the max-overlap meeting. Re-scored + APPLIED 2026-07-17 09:17Z
(backup `sp-runs/sp_backup_mirror_2026-07-17T0917Z`; script backup
`pipeline/sp-rubric-build-mirror.cjs.bak.20260717T091026Z`). Impact on 16 Jul:
493 students ↑ (mostly 0→+10, real evening attendees who'd been under-credited),
35 ↓ (incl. ~20 who only idled in the morning room, 10→0), 204 unchanged.
Dates before the cutover use the identical old code path (no historical change).
- `deltaMode` validator error: schema expects `'absolute' | 'percentage'`. Using `'percent'` (singular) causes validation failure. Fixed in code — only affects legacy transactions created before the fix (May 26 restart).
- **Percentage SP support:** When a chat SP review is accepted with `% SP` (e.g. +10% SP), `deltaMode` is set to `'percentage'`, `deltaValue` holds the percent (e.g. 10), and `appliedDelta` is computed at accept time as `round(currentBalance * deltaValue / 100)`. This works correctly.

Expand Down
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"framer-motion": "^11.15.0",
"html2canvas": "^1.4.1",
"vite": "^5.4.11",
"react": "^18.3.1",
"react-dom": "^18.3.1"
Expand Down
37 changes: 37 additions & 0 deletions client/src/components/replay/EntryPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { motion } from 'framer-motion';

export const EntryPill = ({ kind = 'weekly', onClick }) => {
if (kind === 'final') {
return (
<motion.button
type="button"
className="entry-pill entry-pill--final"
onClick={onClick}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2, scale: 1.01 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<span className="entry-pill__icon" aria-hidden="true">🎉</span>
<span className="entry-pill__text">Your Spurti Journey is Ready!</span>
<span className="entry-pill__chev" aria-hidden="true">→</span>
</motion.button>
);
}
return (
<motion.button
type="button"
className="entry-pill entry-pill--weekly"
onClick={onClick}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2, scale: 1.01 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<span className="entry-pill__icon" aria-hidden="true">🎬</span>
<span className="entry-pill__text">Your Week is Ready!</span>
<span className="entry-pill__chev" aria-hidden="true">→</span>
</motion.button>
);
};
57 changes: 57 additions & 0 deletions client/src/components/replay/FinalJourneyModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnimatePresence>
{open && (
<motion.div className="replay-modal replay-modal--final" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
<div className="replay-modal__stage">
<AnimatePresence mode="wait">
{slides[idx] && (
<StorySlide key={idx} slide={slides[idx]} index={idx} total={slides.length} onNext={goNextAuto} onPrev={go} autoMs={slides[idx].autoMs} />
)}
</AnimatePresence>
</div>
{idx === slides.length - 1 && (
<div className="replay-modal__endbar">
<button type="button" className="replay-endbar__btn" onClick={() => { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('replay:open-share', { detail: { kind: 'final', data: j } })); }}>📤 Share</button>
<button type="button" className="replay-endbar__btn is-primary" onClick={onClose}>Close</button>
</div>
)}
</motion.div>
)}
</AnimatePresence>
);
};
101 changes: 101 additions & 0 deletions client/src/components/replay/ShareCard.tsx
Original file line number Diff line number Diff line change
@@ -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 '<div style="width:600px;padding:32px;background:linear-gradient(135deg,#0F172A 0%,#312E81 100%);color:white;font-family:system-ui,-apple-system,sans-serif;">'
+ '<div style="font-size:14px;letter-spacing:0.12em;text-transform:uppercase;color:rgba(255,255,255,0.7);margin-bottom:12px;">📅 My Week in Spurti</div>'
+ '<div style="font-size:28px;font-weight:800;margin-bottom:24px;">' + (studentName || 'My') + ' Week in Spurti</div>'
+ '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:24px;">'
+ statBlock(data.sessionsAttended || 0, 'Sessions')
+ statBlock(data.pollsAnswered || 0, 'Polls')
+ statBlock('+' + (data.spEarned || 0), 'SP')
+ '</div>'
+ '<div style="font-size:12px;opacity:0.85;text-align:center;">Most improved: <b>' + (data.most_improved || '—') + '</b></div>'
+ '<div style="margin-top:16px;font-size:10px;letter-spacing:0.08em;text-transform:uppercase;opacity:0.5;text-align:center;">Spurti · VLED Summer</div>'
+ '</div>';
}

function statBlock(value, label) {
return '<div style="padding:14px;background:rgba(255,255,255,0.08);border-radius:10px;text-align:center;">'
+ '<div style="font-size:28px;font-weight:900;">' + value + '</div>'
+ '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.8;">' + label + '</div>'
+ '</div>';
}

function buildFinalShareHTML(j, studentName) {
return '<div style="width:600px;padding:32px;background:linear-gradient(135deg,#020617 0%,#BE185D 100%);color:white;font-family:system-ui,-apple-system,sans-serif;">'
+ '<div style="font-size:14px;letter-spacing:0.12em;text-transform:uppercase;color:rgba(255,255,255,0.7);margin-bottom:12px;">🌌 My Spurti Journey</div>'
+ '<div style="font-size:28px;font-weight:800;margin-bottom:18px;">' + (studentName || 'I') + ' completed the journey</div>'
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">'
+ statBlock('#' + j.startRank, 'Start')
+ statBlock('#' + j.endRank, 'End')
+ statBlock('+' + j.totalSp, 'SP Earned')
+ statBlock(j.sessionsAttended, 'Sessions')
+ '</div>'
+ '<div style="padding:12px 16px;background:linear-gradient(135deg,rgba(253,224,71,0.18),rgba(251,146,60,0.18));border-radius:10px;text-align:center;font-weight:800;">🏆 ' + j.bestAchievement + '</div>'
+ '<div style="margin-top:16px;font-size:10px;letter-spacing:0.08em;text-transform:uppercase;opacity:0.5;text-align:center;">Spurti · VLED Summer</div>'
+ '</div>';
}

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 = '<!doctype html><html><head><title>Certificate</title><style>body{font-family:Georgia,serif;text-align:center;padding:60px;background:linear-gradient(135deg,#fff8e1,#fff3d0);}h1{font-size:48px;margin:24px 0;color:#92400e;}.c{border:8px double #b45309;padding:60px;border-radius:12px;background:#fffaf0;}h2{font-size:28px;color:#7c2d12;}.name{font-size:36px;font-weight:700;color:#1e293b;margin:24px 0;}h3{font-size:18px;color:#92400e;}</style></head><body><div class="c"><h1>🏆 Certificate of Completion</h1><h2>VLED Summer Internship · Spurti</h2><p style="font-size:18px;margin-top:24px;">This certifies that</p><p class="name">' + (studentName || 'Student') + '</p><p style="font-size:18px;">has successfully completed the Spurti journey with the title</p><h3>' + ach + '</h3><p style="margin-top:32px;font-size:12px;color:#94a3b8;">Issued by Spurti · VLED Summer</p></div><script>window.onload=function(){window.print();}</script></body></html>';
w.document.write(html);
w.document.close();
}

return (
<AnimatePresence>
{open && (
<motion.div className="share-modal" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose}>
<motion.div className="share-modal__inner" initial={{ scale: 0.94, y: 16 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.94 }} onClick={(e) => e.stopPropagation()}>
<button type="button" className="share-modal__close" onClick={onClose} aria-label="Close">×</button>
<h3 className="share-modal__title">📤 Share Your {kind === 'final' ? 'Journey' : 'Week'}</h3>
<p className="share-modal__lede">Save as an image, share to LinkedIn, or print a certificate.</p>
<div className="share-modal__preview" ref={ref} />
<div className="share-modal__actions">
<button type="button" className="share-modal__btn" onClick={download} disabled={busy}>{busy ? 'Saving…' : '⬇️ Download Image'}</button>
<button type="button" className="share-modal__btn" onClick={shareLinkedIn}>🔗 Share on LinkedIn</button>
{kind === 'final' && <button type="button" className="share-modal__btn" onClick={printCertificate}>📜 Print Certificate</button>}
<button type="button" className="share-modal__btn is-ghost" onClick={onClose}>Close</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};
Loading