@@ -1202,6 +602,7 @@ function AdminView({ admin, auth, onBack }) {
{tab === 'attendance' &&
}
{tab === 'live' &&
}
{tab === 'analytics' &&
}
+ {tab === 'engagement' &&
}
{tab === 'students' &&
}
{studentProfile &&
{studentProfile.student.name} setStudentProfile(null)}>x }
diff --git a/client/src/styles.css b/client/src/styles.css
index cfc87f1..e135100 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -223,7 +223,7 @@ input {
@media (max-width: 720px) { .level-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.pulse-grid {
display: grid;
- grid-template-columns: 1fr 2fr;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@@ -250,45 +250,6 @@ input {
}
.pulse-card p { color: var(--muted); margin-bottom: 6px; }
.wide-pulse { grid-column: span 2; }
-.pulse-clickable { cursor: pointer; text-align: left; border: none; font: inherit; width: 100%; }
-.pulse-clickable:hover { box-shadow: 0 0 0 2px var(--primary) inset; }
-.expand-hint { color: var(--primary); font-style: normal; font-size: 11px; font-weight: 700; margin-left: 6px; }
-.traj-modal { width: min(880px, 100%); }
-.traj-legend { display: flex; flex-wrap: wrap; gap: 16px; margin: 6px 0 4px; }
-.traj-key { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: var(--text); }
-.traj-key i { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
-.traj-chart { width: 100%; overflow-x: auto; }
-.traj-chart svg { width: 100%; height: auto; min-width: 480px; }
-.traj-grid { stroke: var(--line); stroke-width: 1; }
-.traj-axis { fill: var(--muted); font-size: 11px; }
-.traj-axis-title { fill: var(--muted); font-size: 12px; font-weight: 700; }
-.traj-foot { margin-top: 10px; font-size: 12px; }
-
-/* ---- My Journey: goal setup + pace bars --------------------------------- */
-.jr-goalset { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; padding: 10px 0; border-top: 1px solid var(--line); }
-.jr-goalset:first-of-type { border-top: none; padding-top: 4px; }
-.jr-goalset label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); }
-.jr-goalset input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; }
-.jr-missed { color: var(--red); font-size: 12px; font-weight: 800; }
-.jr-progress i.done { background: var(--green); }
-.jr-pill.green { background: #e9f7ee; color: var(--green); }
-.jr-trajlink { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
-.jr-trajlink h2 { margin: 0; }
-.jr-intro h2 { margin-bottom: 4px; }
-.jr-card { display: flex; flex-direction: column; }
-.jr-goal { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
-.jr-goal-label { display: block; font-size: 12.5px; font-weight: 800; color: var(--muted); margin-bottom: 6px; }
-.jr-goal-label.done { color: var(--green); }
-.jr-goal-label.miss { color: var(--red); }
-.jr-goal-meta { display: block; font-size: 13px; font-weight: 800; color: var(--text); margin-bottom: 6px; }
-.jr-goal-foot { display: block; font-size: 12px; color: var(--muted); margin-top: 6px; }
-.jr-goal-row { display: flex; gap: 8px; align-items: center; }
-.jr-goal-row input { flex: 1; min-height: 34px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; }
-.jr-goal-row button { min-height: 34px; white-space: nowrap; }
-.jr-cardfoot { margin-top: auto; padding-top: 12px; display: flex; justify-content: flex-end; }
-.jr-stake { background: none; border: 1px solid var(--primary); color: var(--primary); border-radius: 999px; padding: 6px 12px; font-weight: 800; font-size: 12px; cursor: pointer; }
-.jr-stake:hover { background: var(--primary); color: #fff; }
-.jr-goal-hint { display: block; font-size: 11.5px; color: var(--muted); margin-top: 6px; }
.compare-list { display: grid; gap: 8px; }
.compare-list b { font-size: 14px; }
.badge-row { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -321,10 +282,6 @@ input {
.empty { color: var(--muted); }
.bank { display: grid; gap: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
-.bank-controls { display: flex; gap: 10px; align-items: center; }
-.bank-controls label { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--muted); font-weight: 700; }
-.bank-controls select { min-height: 32px; border: 1px solid var(--line); border-radius: 8px; padding: 0 6px; }
-.bank-foot { margin-top: 10px; font-size: 12.5px; }
.bank-header, .bank-row {
display: grid;
grid-template-columns: 180px 80px 80px 80px minmax(260px, 1fr);
@@ -557,134 +514,3 @@ input {
.survey-primary:disabled { opacity: 0.6; cursor: default; }
.survey-ghost { background: #fff; color: #475569; border-color: #cbd5e1; }
.survey-note { margin: 0 24px 16px; font-size: 0.85rem; color: #b91c1c; }
-
-/* --- ViBe Goals tab -------------------------------------------------------- */
-.vg .muted { color: var(--muted); font-size: 13px; }
-.vg .hint { font-size: 12px; color: var(--muted); }
-.vg-ladder { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
-.vg-step { flex: 1; min-width: 150px; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; background: #fafdff; position: relative; }
-.vg-step .n { position: absolute; top: 10px; right: 12px; width: 20px; height: 20px; border-radius: 50%; background: #e2e8f0; color: var(--muted); font-size: 12px; font-weight: 900; display: grid; place-items: center; }
-.vg-step b { display: block; font-size: 15px; margin-bottom: 2px; }
-.vg-step em { font-style: normal; font-size: 12px; color: var(--muted); }
-.vg-step.done { background: #eefaf3; border-color: #bbe7cf; }
-.vg-step.done .n { background: var(--green); color: #fff; }
-.vg-step.current { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(23,107,135,.18); }
-.vg-step.current .n { background: var(--primary); color: #fff; }
-.vg-step.locked { opacity: .7; }
-.vg-arrow { display: grid; place-items: center; color: var(--muted); font-size: 20px; font-weight: 900; }
-.vg-tiles { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; }
-.vg-tile { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fafdff; }
-.vg-tile > span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; font-weight: 800; }
-.vg-tile > strong { display: block; font-size: 22px; margin: 6px 0 2px; color: var(--primary); }
-.vg-tile > em { display: block; color: var(--muted); font-style: normal; font-size: 12px; }
-.vg-tile.done { background: #eefaf3; border-color: #bbe7cf; }
-.vg-tile.done > strong { color: var(--green); }
-.vg-pill { display: inline-block; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; }
-.vg-pill.green { background: #e9f7ee; color: var(--green); }
-.vg-pill.amber { background: #fef3e2; color: var(--amber); }
-.vg-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin-top: 8px; }
-.vg-progress i { display: block; height: 100%; background: var(--primary); }
-.vg-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
-.vg-field { display: grid; gap: 6px; }
-.vg-field label { font-size: 13px; font-weight: 800; color: #334155; }
-.vg-field input { width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 10px 12px; background: #fff; color: var(--text); }
-.vg-field input[type=range] { padding: 0; accent-color: var(--primary); }
-.vg-row { display: flex; align-items: center; gap: 8px; }
-.vg-row input { max-width: 120px; }
-.vg-wide { grid-column: 1 / -1; }
-.vg-mult { display: flex; gap: 8px; }
-.vg-mult button { flex: 1; border: 1px solid var(--line); background: #fff; border-radius: 7px; padding: 10px 0; font-weight: 850; color: var(--text); }
-.vg-mult button.active { background: var(--primary); border-color: var(--primary); color: #fff; }
-.vg-readout { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; border-top: 1px solid var(--line); padding-top: 14px; }
-.vg-readout .r { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: #fbfdff; text-align: center; }
-.vg-readout .r span { display: block; font-size: 12px; color: var(--muted); font-weight: 800; }
-.vg-readout .r strong { display: block; font-size: 20px; margin-top: 4px; }
-.vg-readout .win strong { color: var(--green); }
-.vg-readout .lose strong { color: var(--red); }
-.vg-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
-.vg-warn { color: var(--red); font-weight: 800; font-size: 13px; }
-.vg-ok { color: var(--green); font-weight: 800; font-size: 13px; }
-.vg-lock { border: 1px dashed var(--primary); background: #f0f8fb; color: var(--primary-dark); border-radius: 8px; padding: 12px 14px; font-weight: 700; font-size: 14px; margin-bottom: 14px; }
-.vg-bet { border: 1px solid var(--line); border-left: 4px solid var(--primary); border-radius: 8px; padding: 14px; background: #fff; display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; }
-.vg-bet h4 { margin: 0 0 4px; font-size: 15px; }
-.vg-bet .meta { color: var(--muted); font-size: 13px; }
-.vg-bet .side { text-align: right; }
-.vg-bet .side .win { color: var(--green); font-weight: 850; }
-.vg-bet .side .lose { color: var(--red); font-weight: 850; }
-.vg-betbtns { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; }
-.vg-betbtns button { min-height: 34px; padding: 0 10px; }
-.vg-hit { color: var(--green); font-weight: 850; }
-.vg-miss { color: var(--red); font-weight: 850; }
-@media (max-width: 820px) { .vg-form { grid-template-columns: 1fr; } .vg-readout { grid-template-columns: 1fr 1fr; } .vg-tiles { grid-template-columns: 1fr; } }
-.vg-readout .net { display: block; font-size: 11px; color: var(--muted); font-weight: 700; margin-top: 2px; }
-
-/* ---- My Journey (phase-by-phase progress + SP) ---------------------------- */
-.jr { display: grid; gap: 16px; }
-.jr-plan-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; margin-top: 6px; }
-.jr-plan-row label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 700; color: var(--muted); }
-.jr-plan-row input { min-height: 36px; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; }
-.jr-saved { color: var(--green); font-weight: 800; font-size: 13px; }
-
-.jr-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
-@media (max-width: 720px) { .jr-grid { grid-template-columns: 1fr; } }
-
-.jr-card { background: var(--panel, #fff); border: 1px solid var(--line); border-radius: 12px; padding: 16px; border-top: 4px solid var(--primary); box-shadow: var(--shadow, 0 1px 2px rgba(0,0,0,.04)); }
-.jr-card.phase-standups { border-top-color: #3b82f6; }
-.jr-card.phase-vibe { border-top-color: #8b5cf6; }
-.jr-card.phase-spa { border-top-color: #f59e0b; }
-.jr-card.phase-project { border-top-color: #10b981; }
-
-.jr-head { display: flex; align-items: center; gap: 8px; }
-.jr-head h3 { margin: 0; font-size: 16px; flex: 1; }
-.jr-n { width: 22px; height: 22px; border-radius: 50%; background: var(--text); color: #fff; font-size: 12px; font-weight: 800; display: grid; place-items: center; }
-.jr-sp { font-weight: 850; color: var(--green); font-size: 15px; }
-.jr-sp.neg { color: var(--red); }
-.jr-soon { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 3px 8px; }
-.jr-sub { color: var(--muted); font-size: 13px; margin: 6px 0 12px; }
-
-.jr-stats { display: flex; gap: 18px; flex-wrap: wrap; }
-.jr-stats div { display: flex; flex-direction: column; }
-.jr-stats strong { font-size: 22px; line-height: 1.1; }
-.jr-stats span { font-size: 12px; color: var(--muted); }
-.jr-big { display: flex; align-items: baseline; gap: 6px; }
-.jr-big strong { font-size: 30px; }
-.jr-big span { color: var(--muted); font-size: 13px; }
-
-.jr-dots { display: flex; gap: 8px; }
-.jr-dot { flex: 1; text-align: center; border: 1px solid var(--line); border-radius: 8px; padding: 8px 4px; }
-.jr-dot.done { background: #ede9fe; border-color: #8b5cf6; }
-.jr-dot.current { background: #f5f3ff; border-color: #8b5cf6; box-shadow: inset 0 0 0 1px #8b5cf6; }
-.jr-dot b { display: block; font-size: 15px; }
-.jr-dot span { font-size: 10px; color: var(--muted); }
-
-.jr-progress { height: 12px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin: 8px 0; }
-.jr-progress i { display: block; height: 100%; background: #f59e0b; }
-
-.jr-splits { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
-.jr-pill { display: inline-block; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; background: #eef2ff; color: var(--text); }
-.jr-pill.amber { background: #fef3e2; color: var(--amber); }
-.jr-pill.muted { background: #f1f5f9; color: var(--muted); font-weight: 600; }
-.jr-link { background: none; border: none; color: var(--primary); font-weight: 800; font-size: 12px; cursor: pointer; padding: 0; margin-left: auto; }
-
-/* ---- Commitments hub (sub-tabs, one phase at a time) --------------------- */
-.cm { display: grid; gap: 12px; }
-.cm-subtabs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
-.cm-subtab { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 8px 14px; font: inherit; font-weight: 700; font-size: 13px; color: var(--muted); cursor: pointer; }
-.cm-subtab:hover { border-color: var(--primary); color: var(--text); }
-.cm-subtab.active { background: var(--primary); border-color: var(--primary); color: #fff; }
-.cm-subtab.active .cm-tag { background: rgba(255, 255, 255, 0.24); color: #fff; }
-.cm-acc { border: 1px solid var(--line); border-radius: 12px; background: var(--panel, #fff); overflow: hidden; border-left: 4px solid var(--line); }
-.cm-acc.open { box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,.06)); }
-.cm-acc.phase-vibe.open { border-left-color: #8b5cf6; }
-.cm-acc.phase-standup.open { border-left-color: #3b82f6; }
-.cm-acc.phase-spa.open { border-left-color: #f59e0b; }
-.cm-acc.phase-project.open { border-left-color: #10b981; }
-.cm-accbtn { width: 100%; display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: none; border: none; cursor: pointer; text-align: left; font: inherit; }
-.cm-accbtn b { font-size: 15px; }
-.cm-caret { color: var(--muted); font-size: 12px; width: 12px; }
-.cm-tag { font-size: 11px; font-weight: 800; color: var(--muted); background: #f1f5f9; border-radius: 999px; padding: 2px 8px; }
-.cm-blurb { color: var(--muted); font-size: 12.5px; margin-left: auto; text-align: right; max-width: 46%; }
-@media (max-width: 620px) { .cm-blurb { display: none; } }
-.cm-body { padding: 4px 14px 14px; border-top: 1px solid var(--line); }
-.cm-body .vg { margin-top: 8px; }
-.cm-soon { color: var(--muted); font-size: 14px; line-height: 1.6; padding: 10px 2px; }
diff --git a/client/src/styles/minecraft-tokens.css b/client/src/styles/minecraft-tokens.css
new file mode 100644
index 0000000..162d7ed
--- /dev/null
+++ b/client/src/styles/minecraft-tokens.css
@@ -0,0 +1,351 @@
+:root {
+ --mc-redstone: #C4695C;
+ --mc-redstone-dark: #8D4A3E;
+ --mc-redstone-fleck: #D99B8F;
+ --mc-gold: #F5B342;
+ --mc-gold-dark: #C67C00;
+ --mc-gold-fleck: #FFD966;
+ --mc-emerald: #4CAF50;
+ --mc-emerald-dark: #388E3C;
+ --mc-emerald-fleck: #81C784;
+ --mc-diamond: #5B9BD5;
+ --mc-diamond-dark: #3A7BBF;
+ --mc-diamond-fleck: #89C4F4;
+ --mc-cobble: #8A8A8A;
+ --mc-cobble-dark: #5C5C5C;
+ --mc-cobble-fleck: #A8A8A8;
+
+ --mc-bevel-light: rgba(255,255,255,0.30);
+ --mc-bevel-dark: rgba(0,0,0,0.30);
+ --mc-tooltip-bg: #100010ee;
+}
+
+.mc-glow-Excellent { box-shadow: 0 0 12px rgba(91,155,213,0.6), 0 0 24px rgba(91,155,213,0.3); }
+.mc-glow-Active { box-shadow: 0 0 12px rgba(76,175,80,0.6), 0 0 24px rgba(76,175,80,0.3); }
+.mc-glow-Slowing { box-shadow: 0 0 12px rgba(196,105,92,0.5), 0 0 24px rgba(196,105,92,0.25); }
+.mc-glow-Recovery { box-shadow: 0 0 12px rgba(245,179,66,0.6), 0 0 24px rgba(245,179,66,0.3); }
+
+.mc-block {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-top: 3px solid var(--mc-bevel-light);
+ border-left: 3px solid var(--mc-bevel-light);
+ border-bottom: 3px solid var(--mc-bevel-dark);
+ border-right: 3px solid var(--mc-bevel-dark);
+ image-rendering: pixelated;
+ position: relative;
+ overflow: hidden;
+}
+
+.mc-block-sm { width: 32px; height: 32px; }
+.mc-block-md { width: 48px; height: 48px; }
+.mc-block-lg { width: 64px; height: 64px; }
+
+.mc-fleck {
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ border-radius: 0;
+ opacity: 0.5;
+}
+
+/* Band-specific block colors */
+.mc-band-Excellent {
+ background: var(--mc-diamond);
+ box-shadow: inset 0 0 0 1px var(--mc-diamond-dark);
+}
+.mc-band-Excellent .mc-fleck { background: var(--mc-diamond-fleck); }
+
+.mc-band-Active {
+ background: var(--mc-emerald);
+ box-shadow: inset 0 0 0 1px var(--mc-emerald-dark);
+}
+.mc-band-Active .mc-fleck { background: var(--mc-emerald-fleck); }
+
+.mc-band-Slowing {
+ background: var(--mc-redstone);
+ box-shadow: inset 0 0 0 1px var(--mc-redstone-dark);
+}
+.mc-band-Slowing .mc-fleck { background: var(--mc-redstone-fleck); }
+
+.mc-band-Recovery {
+ background: var(--mc-gold);
+ box-shadow: inset 0 0 0 1px var(--mc-gold-dark);
+}
+.mc-band-Recovery .mc-fleck { background: var(--mc-gold-fleck); }
+
+.mc-band-Insufficient {
+ background: var(--mc-cobble);
+ box-shadow: inset 0 0 0 1px var(--mc-cobble-dark);
+}
+.mc-band-Insufficient .mc-fleck { background: var(--mc-cobble-fleck); }
+
+/* Tooltip style - Minecraft inventory item tooltip */
+.mc-tooltip {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--mc-tooltip-bg);
+ color: #fff;
+ font-size: 12px;
+ line-height: 1.4;
+ padding: 8px 10px;
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 100;
+ border-top: 2px solid var(--mc-bevel-light);
+ border-left: 2px solid var(--mc-bevel-light);
+ border-bottom: 2px solid var(--mc-bevel-dark);
+ border-right: 2px solid var(--mc-bevel-dark);
+ max-width: 280px;
+ white-space: normal;
+ text-align: center;
+}
+
+.mc-tooltip::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 5px solid transparent;
+ border-top-color: var(--mc-tooltip-bg);
+}
+
+/* Pixel font for band labels */
+.mc-label {
+ font-family: 'Press Start 2P', monospace;
+ font-size: 9px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.mc-label-lg {
+ font-family: 'Press Start 2P', monospace;
+ font-size: 11px;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+/* Band card container */
+.mc-card {
+ border: 2px solid var(--line);
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.mc-card-info {
+ display: grid;
+ gap: 4px;
+}
+
+.mc-card-info .mc-label {
+ color: var(--text);
+}
+
+.mc-card-info .mc-reason {
+ font-size: 13px;
+ color: var(--muted);
+ line-height: 1.4;
+ margin: 2px 0 0;
+}
+
+.mc-card-info .mc-stats {
+ display: flex;
+ gap: 12px;
+ margin-top: 4px;
+}
+
+.mc-card-info .mc-stat {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.mc-card-info .mc-stat strong {
+ color: var(--text);
+ font-size: 13px;
+}
+
+/* History trail */
+.mc-trail {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ margin-left: auto;
+}
+
+.mc-trail-label {
+ font-size: 10px;
+ color: var(--muted);
+ margin-right: 4px;
+}
+
+/* Admin grid */
+.mc-grid-wrap {
+ display: grid;
+ gap: 16px;
+}
+
+.mc-band-row {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 12px;
+ background: var(--panel);
+}
+
+.mc-band-row h3 {
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 10px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.mc-band-row h3 span {
+ font-size: 11px;
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.mc-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.mc-grid-item {
+ position: relative;
+ cursor: pointer;
+}
+
+.mc-filter-bar {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+}
+
+.mc-filter-btn {
+ font-family: 'Press Start 2P', monospace;
+ font-size: 8px;
+ letter-spacing: 0.5px;
+ border: 2px solid var(--line);
+ background: var(--panel);
+ padding: 8px 12px;
+ cursor: pointer;
+ text-transform: uppercase;
+ border-top: 3px solid var(--mc-bevel-light);
+ border-left: 3px solid var(--mc-bevel-light);
+ border-bottom: 3px solid var(--mc-bevel-dark);
+ border-right: 3px solid var(--mc-bevel-dark);
+}
+
+.mc-filter-btn.active {
+ background: var(--primary);
+ color: #fff;
+ border-color: var(--primary-dark);
+}
+
+.mc-loading {
+ font-family: 'Press Start 2P', monospace;
+ font-size: 8px;
+ color: var(--muted);
+ text-align: center;
+ padding: 20px;
+}
+
+.mc-dimmed {
+ filter: saturate(0.2) brightness(0.65);
+ opacity: 0.6;
+}
+
+.mc-active-block {
+ filter: saturate(1.1) brightness(1.05);
+}
+
+.mc-spectrum {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+}
+
+.mc-spectrum-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ position: relative;
+}
+
+.mc-spectrum-item .mc-label {
+ font-size: 7px;
+ color: var(--muted);
+ transition: color 0.2s;
+}
+
+.mc-spectrum-item.active .mc-label {
+ color: var(--text);
+ font-size: 8px;
+ font-weight: 700;
+}
+
+.mc-spectrum-item.active .mc-label-lg {
+ font-size: 10px;
+ color: var(--text);
+}
+
+.mc-spectrum-connector {
+ flex: 0 0 auto;
+ height: 4px;
+ width: 20px;
+ background: #d0d7e2;
+ margin: 0 -8px;
+ border-radius: 2px;
+ transition: background 0.3s;
+}
+
+.mc-spectrum-connector.active {
+ background: var(--primary);
+}
+
+.mc-spec-card {
+ border: 2px solid var(--line);
+ border-radius: 8px;
+ padding: 16px;
+ background: var(--panel);
+ box-shadow: var(--shadow);
+ margin-bottom: 18px;
+}
+
+.mc-spec-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.mc-spec-header h3 {
+ margin: 0;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--muted);
+}
+
+.mc-spec-reason {
+ font-size: 12px;
+ color: var(--muted);
+ margin: 8px 0 0;
+ text-align: center;
+ line-height: 1.4;
+}
diff --git a/pipeline/sp-rubric-build-mirror.cjs b/pipeline/sp-rubric-build-mirror.cjs
index 1577ea9..3685335 100644
--- a/pipeline/sp-rubric-build-mirror.cjs
+++ b/pipeline/sp-rubric-build-mirror.cjs
@@ -65,50 +65,12 @@ const APPLY = process.env.APPLY === '1';
// 09:05 IST = 03:35 UTC. wEnd = min(first-instance-end, 11:00 IST). Per-day end overrides (IST) take precedence.
const WINDOW_END_OVERRIDE_IST = { '2026-05-22': '11:00' };
-// From EVENING_CUTOVER the daily standup moved from the morning (09:05-11:00 IST)
-// to the evening. On/after this date, score presence in [EVENING_WSTART_IST,
-// EVENING_WEND_IST] IST (5-min join grace, mirroring the old morning window) and
-// pick the mandatory meeting that overlaps THAT window (an all-day/leftover
-// morning room must not steal the slot). Dates before the cutover are unchanged.
-const EVENING_CUTOVER = '2026-07-16';
-const EVENING_WSTART_IST = '20:05';
-const EVENING_WEND_IST = '21:00';
const GRACE_DATE = '2026-06-06'; // exceptional: 1 min join = full att + full poll
-// Polls also moved off Zoom to the Spandan evening classroom at the evening
-// cutover. On/after this date the poll (B) score comes from `spandan_polls`
-// (correctness, percentiled to the day's top scorer); strictly before it, from
-// the frozen `zoom_polls` mirror (participation) exactly as history has it — so
-// old poll SP is never disturbed. Same date as the evening attendance cutover.
-const SPANDAN_CUTOFF = process.env.SPANDAN_CUTOFF || EVENING_CUTOVER;
const STAFF = new Set([
'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com',
'sudarshansudarshan@gmail.com', 'sudarshan@iitrpr.ac.in', 'rajankrsna@gmail.com',
]);
-// ── SPA → SP (peer-teaching endorsement points; Pattern A: rubric-recomputed) ──
-// SP for the SPA activity, scored here alongside attendance/poll so it is
-// regenerated every rebuild (wipe-safe by construction — no preserved category).
-// Only VALIDATED endorsements count (status approved|audit_passed); the raw
-// act_spa_transactions.deltaSPA ledger is a runaway compounding value and is
-// never used. Two capped tracks + a one-time integrity penalty on current SP.
-// Source: act_spa_endorsements + act_spa_transactions (mirrored 6-hourly).
-const SPA_LEARN_UNIT = 5, SPA_LEARN_CAP = 50; // +5 SP / validated question learned, cap 50 → max 250
-const SPA_TEACH_UNIT = 8, SPA_TEACH_CAP = 30; // +8 SP / validated peer taught, cap 30 → max 240
-const SPA_FRAUD_RATE = 0.5, SPA_AUDIT_RATE = 0.2;
-const SPA_GOOD = ['approved', 'audit_passed'];
-
-// ── Query answering → SP (Pattern A: rubric-recomputed) ──────────────────────
-// +5 SP per DISTINCT peer query a student answered (from
-// act_query_reviews.peer.submittedAnswerHistory), self-answers excluded, no cap.
-// Answering only — asking a question earns nothing.
-const QUERY_UNIT = 5;
-
-// ── PRESERVED categories — NOT recomputable from Zoom source, so they must survive
-// the delete-and-rebuild (else the wipe erases them every run). 'manual' = ViBe/
-// standup commitment SP (stake debits + wins) AND admin manual awards; 'peer_faq' =
-// peer-review FAQ awards. We fold their deltas back into each student's balance.
-const PRESERVED_CATS = ['manual', 'peer_faq'];
-
const isMandatory = (t) => /stand|orientation/i.test(t) && !/breakout|weekend|nptel|special|support|non[- ]?mandatory/i.test(t);
const tier = (pct) => { pct = Math.min(100, pct); return pct >= 90 ? 10 : pct >= 75 ? 5 : pct >= 50 ? 3 : 0; };
const dstr = (d) => { if (!d) return null; const x = new Date(d); return isNaN(x) ? null : x.toISOString().slice(0, 10); };
@@ -154,53 +116,16 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+)
const byDate = {}; for (const m of meetings) (byDate[m.date] = byDate[m.date] || []).push(m);
const sessions = [];
for (const date of Object.keys(byDate).sort()) {
- const mandatory = byDate[date].filter((m) => isMandatory(m.topic) && (m.participantsCount || 0) >= 10);
- if (!mandatory.length) continue;
- let first, wStart, wEnd;
- if (date >= EVENING_CUTOVER) {
- // evening standup: fixed [20:05, 21:00] IST window; pick the mandatory meeting
- // that overlaps it most so a leftover all-day/morning room can't steal the slot.
- wStart = utcFromISTDate(date, EVENING_WSTART_IST);
- const wCap = utcFromISTDate(date, EVENING_WEND_IST);
- const scored = mandatory.map((m) => {
- const ms = new Date(m.startTime).getTime(), me = new Date(m.endTime).getTime();
- return { m, ov: Math.max(0, Math.min(me, wCap) - Math.max(ms, wStart)) };
- }).sort((a, b) => b.ov - a.ov)[0];
- if (!scored || scored.ov <= 0) continue; // no mandatory meeting overlaps the evening window
- first = scored.m;
- wEnd = Math.min(new Date(first.endTime).getTime(), wCap);
- } else {
- first = mandatory.sort((a, b) => new Date(a.startTime) - new Date(b.startTime))[0];
- wStart = utcFromISTDate(date, '09:05');
- wEnd = WINDOW_END_OVERRIDE_IST[date] ? utcFromISTDate(date, WINDOW_END_OVERRIDE_IST[date]) : Math.min(new Date(first.endTime).getTime(), utcFromISTDate(date, '11:00'));
- }
+ const first = byDate[date].filter((m) => isMandatory(m.topic) && (m.participantsCount || 0) >= 10).sort((a, b) => new Date(a.startTime) - new Date(b.startTime))[0];
+ if (!first) continue;
+ const wStart = utcFromISTDate(date, '09:05');
+ const wEnd = WINDOW_END_OVERRIDE_IST[date] ? utcFromISTDate(date, WINDOW_END_OVERRIDE_IST[date]) : Math.min(new Date(first.endTime).getTime(), utcFromISTDate(date, '11:00'));
sessions.push({ date, uuid: first._id, topic: first.topic, wStart, wEnd, label: dayLabel(first.topic) });
}
// 3. per-student per-session attendance (A) + poll (B), all from the mirror
const students = new Map(); // email -> { name, firstAtt, rows:[{date,order,cat,delta,reason}] }
const touch = (email, name) => { const e = email.toLowerCase().trim(); if (!students.has(e)) students.set(e, { name: name || e, firstAtt: null, rows: [] }); const o = students.get(e); if (name && !name.includes('@')) o.name = name; return o; };
-
- // Spandan evening-poll mirror (Day-N sessions >= SPANDAN_CUTOFF), keyed by date.
- // Poll (B) here is correctness-based, percentiled to the day's TOP scorer:
- // pct = pointsEarned / dayTopPoints * 100, then the same 10/5/3/0 band ladder.
- const spandanByDate = new Map();
- for (const sp of await sak.collection('spandan_polls').find({ date: { $gte: SPANDAN_CUTOFF } }).toArray()) {
- const prev = spandanByDate.get(sp.date);
- if (!prev || (sp.studentCount || 0) > (prev.studentCount || 0)) spandanByDate.set(sp.date, sp); // one Day-N/day; keep the fullest
- }
- const scoreSpandanPoll = (sp, label) => {
- const top = sp.topPoints || (sp.students || []).reduce((mx, x) => Math.max(mx, x.pointsEarned || 0), 0);
- for (const x of sp.students || []) {
- const e = String(x.email || '').toLowerCase().trim(); if (!e) continue;
- const pct = top ? Math.round((x.pointsEarned || 0) / top * 1000) / 10 : 0;
- const d = tier(pct);
- // Short bank message: conveys correctness-based + relative-to-day-top in one line.
- touch(e).rows.push({ date: sp.date, order: 2, cat: 'poll', delta: d,
- reason: `${label} (${ddmon(sp.date)}): ${pct}% of day's top poll score -> ${d > 0 ? '+' : ''}${d} SP (correctness-based).` });
- }
- };
-
for (const s of sessions) {
const winMin = Math.round((s.wEnd - s.wStart) / 60000);
// attendance via zoom_attendance mirror (firstJoin/lastLeave), clipped to window
@@ -221,95 +146,21 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+)
touch(e, v.name).rows.push({ date: s.date, order: 1, cat: 'attendance', delta: d, reason: `${s.label} (${ddmon(s.date)}): present ${mins} of ${winMin} min (${pct}%) within official ${istHHMM(s.wStart)}-${istHHMM(s.wEnd)} IST window -> ${d > 0 ? '+' : ''}${d} SP.` });
const o = students.get(e); if (!o.firstAtt || s.date < o.firstAtt) o.firstAtt = s.date;
}
- // poll (B): Spandan evening performance on/after the cutoff; Zoom participation before it.
- if (s.date >= SPANDAN_CUTOFF) {
- const sp = spandanByDate.get(s.date);
- if (sp) { scoreSpandanPoll(sp, s.label); spandanByDate.delete(s.date); } // consumed: covered by an evening session
- } else {
- // poll participation via zoom_polls for the same instance
- const polls = await sak.collection('zoom_polls').find({ meetingUuid: s.uuid }).toArray();
- const totalQ = new Set(polls.map((p) => p.question)).size;
- if (totalQ > 0) {
- const ans = new Map(); for (const p of polls) { const e = String(p.email || '').toLowerCase().trim(); if (!e) continue; if (!ans.has(e)) ans.set(e, new Set()); if (p.answer && String(p.answer).trim()) ans.get(e).add(p.question); }
- const present = new Set([...segByEmail.keys(), ...ans.keys()]);
- for (const e of present) {
- const a = (s.date === GRACE_DATE && segByEmail.has(e)) ? totalQ : (ans.get(e) || new Set()).size; const pct = Math.round(a / totalQ * 1000) / 10; const d = tier(pct);
- touch(e).rows.push({ date: s.date, order: 2, cat: 'poll', delta: d, reason: `${s.label} (${ddmon(s.date)}): answered ${a} of ${totalQ} poll questions (${pct}%) -> ${d > 0 ? '+' : ''}${d} SP.` });
- }
+ // poll participation via zoom_polls for the same instance
+ const polls = await sak.collection('zoom_polls').find({ meetingUuid: s.uuid }).toArray();
+ const totalQ = new Set(polls.map((p) => p.question)).size;
+ if (totalQ > 0) {
+ const ans = new Map(); for (const p of polls) { const e = String(p.email || '').toLowerCase().trim(); if (!e) continue; if (!ans.has(e)) ans.set(e, new Set()); if (p.answer && String(p.answer).trim()) ans.get(e).add(p.question); }
+ const present = new Set([...segByEmail.keys(), ...ans.keys()]);
+ for (const e of present) {
+ const a = (s.date === GRACE_DATE && segByEmail.has(e)) ? totalQ : (ans.get(e) || new Set()).size; const pct = Math.round(a / totalQ * 1000) / 10; const d = tier(pct);
+ touch(e).rows.push({ date: s.date, order: 2, cat: 'poll', delta: d, reason: `${s.label} (${ddmon(s.date)}): answered ${a} of ${totalQ} poll questions (${pct}%) -> ${d > 0 ? '+' : ''}${d} SP.` });
}
}
}
- // Spandan poll days with no mandatory evening session still earn poll SP (label from the Day number).
- for (const [, sp] of spandanByDate) scoreSpandanPoll(sp, 'Day ' + sp.dayNumber);
-
- // 3b. SPA → per-canon validated learn/teach events (dated) + integrity flags.
- // emailToCanon is fully built by now, so we can fold aliases correctly.
- const spaByCanon = new Map(); // canon -> { learn:[YYYY-MM-DD...], teach:[...] }
- const spaFlag = new Map(); // canon -> { auditFail?, fraud? }
- const canonOf = (e) => { const k = String(e || '').toLowerCase().trim(); return emailToCanon.get(k) || k; };
- const touchSpa = (c) => { let o = spaByCanon.get(c); if (!o) { o = { learn: [], teach: [] }; spaByCanon.set(c, o); } return o; };
- for (const en of await sak.collection('act_spa_endorsements').find(
- { status: { $in: SPA_GOOD } },
- { projection: { learnerEmail: 1, teacherEmail: 1, approvedAt: 1, updatedAt: 1, createdAt: 1 } }).toArray()) {
- const d = dstr(en.approvedAt) || dstr(en.updatedAt) || dstr(en.createdAt); if (!d) continue;
- if (en.learnerEmail) touchSpa(canonOf(en.learnerEmail)).learn.push(d);
- if (en.teacherEmail) touchSpa(canonOf(en.teacherEmail)).teach.push(d);
- }
- // Genuine fraud = net teacher_fraud_penalty + fraud_penalty_reversal < 0, with
- // operator "Testing" rows excluded (they are demote-feature tests, all reversed).
- for (const f of await sak.collection('act_spa_transactions').aggregate([
- { $match: { transactionType: { $in: ['teacher_fraud_penalty', 'fraud_penalty_reversal'] }, reason: { $not: /testing/i } } },
- { $group: { _id: { $toLower: '$email' }, net: { $sum: '$deltaSPA' } } }]).toArray()) {
- if (f.net < 0) { const c = canonOf(f._id); spaFlag.set(c, { ...(spaFlag.get(c) || {}), fraud: true }); }
- }
- for (const a of await sak.collection('act_spa_transactions').aggregate([
- { $match: { transactionType: { $in: ['audit_failure_learner_penalty', 'audit_failure_teacher_penalty'] } } },
- { $group: { _id: { $toLower: '$email' } } }]).toArray()) {
- const c = canonOf(a._id); spaFlag.set(c, { ...(spaFlag.get(c) || {}), auditFail: true });
- }
-
- // 3d. Query answering → per-canon distinct queries answered (dated). Answerer =
- // peer.submittedAnswerHistory (userIds); map userId→email via an act_* crosswalk,
- // canonicalize, and drop self-answers (answerer == asker). Non-students fall out
- // naturally (only canons in `candidates` get rows below).
- const uidToEmail = new Map();
- for (const c of ['act_query_reviews', 'act_pull_requests', 'act_cs_faq', 'act_spa_rosters']) {
- for (const r of await sak.collection(c).find({ userId: { $ne: null }, email: { $ne: null } }, { projection: { userId: 1, email: 1 } }).toArray())
- uidToEmail.set(String(r.userId), String(r.email).toLowerCase().trim());
- }
- const queryByCanon = new Map(); // canon -> [YYYY-MM-DD ...] (one per distinct query answered)
- for (const q of await sak.collection('act_query_reviews').find(
- { 'peer.submittedAnswerHistory.0': { $exists: true } },
- { projection: { userId: 1, createdAt: 1, updatedAt: 1, 'peer.submittedAnswerHistory': 1, 'peer.answer.submittedAt': 1 } }).toArray()) {
- const askerId = String(q.userId);
- const date = dstr(q.peer?.answer?.submittedAt) || dstr(q.createdAt) || dstr(q.updatedAt); if (!date) continue;
- const seen = new Set();
- for (let uid of (q.peer.submittedAnswerHistory || [])) {
- uid = String(uid); if (uid === askerId || seen.has(uid)) continue; seen.add(uid);
- const e = uidToEmail.get(uid); if (!e) continue;
- const c = canonOf(e);
- let arr = queryByCanon.get(c); if (!arr) { arr = []; queryByCanon.set(c, arr); }
- arr.push(date);
- }
- }
-
- // 3e. PRESERVED rows (manual/peer_faq) — read BEFORE the wipe and fold into each
- // student's ledger so commitment/admin SP survives the rebuild. Re-created with
- // the same delta/date/reason (metadata like original createdAt is not retained).
- const preservedByCanon = new Map(); // canon -> [{ date, order, cat, delta, reason }]
- for (const t of await sak.collection('sptransactions').find({ category: { $in: PRESERVED_CATS } }).toArray()) {
- const e = String(t.email || '').toLowerCase().trim(); if (!e) continue;
- const c = canonOf(e);
- const date = dstr(t.dateTime) || dstr(t.createdAt) || TODAY;
- const delta = Number(typeof t.appliedDelta === 'number' ? t.appliedDelta : t.deltaValue) || 0;
- let arr = preservedByCanon.get(c); if (!arr) { arr = []; preservedByCanon.set(c, arr); }
- arr.push({ date, order: 5, cat: t.category, delta, reason: t.reason || '' });
- }
-
// 4. assemble ledger, ROSTER-DRIVEN union: base 100 to every started intern.
const ledger = []; const setAside = []; const finalBal = new Map(); const zeroOut = []; const nameByCanon = new Map();
- const spaSummary = []; // per-canon SPA breakdown for the web-app SPA tab (spaprogresses)
const candidates = new Map(); // identity email -> { start, emails:[..], name }
// (a) confirmed candidates interns (incl. those who never attended). Start comes
// from the person's OWN record (internStart), not an alias-polluted lookup.
@@ -330,42 +181,9 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+)
const best = new Map();
for (const e of info.emails) { const o = students.get(e); if (!o) continue; if (info.name === cand && o.name && !o.name.includes('@')) info.name = o.name; for (const r of o.rows) { if (r.date < info.start) continue; const k = r.date + '|' + r.cat; const cur = best.get(k); if (!cur || r.delta > cur.delta) best.set(k, r); } }
const rows = [{ date: info.start, order: 0, cat: 'initial', delta: 100, reason: `Base Spurti Points (100) credited on internship start date ${info.start}.` }, ...best.values()];
- // SPA rows (Pattern A): one consolidated 'spa' row per day, cumulative caps
- // across days. These bypass the (date|cat) best-dedup by being pushed directly.
- const spa = spaByCanon.get(cand); const flags = spaFlag.get(cand) || {};
- let spaLearnUsed = 0, spaTeachUsed = 0;
- if (spa) {
- const byDay = new Map(); // date -> { learn, teach }
- for (const d of spa.learn.slice().sort()) { if (spaLearnUsed >= SPA_LEARN_CAP) break; spaLearnUsed++; const o = byDay.get(d) || { learn: 0, teach: 0 }; o.learn++; byDay.set(d, o); }
- for (const d of spa.teach.slice().sort()) { if (spaTeachUsed >= SPA_TEACH_CAP) break; spaTeachUsed++; const o = byDay.get(d) || { learn: 0, teach: 0 }; o.teach++; byDay.set(d, o); }
- for (const [d, o] of byDay) {
- const delta = o.learn * SPA_LEARN_UNIT + o.teach * SPA_TEACH_UNIT; if (!delta) continue;
- const parts = []; if (o.learn) parts.push(`${o.learn} learned`); if (o.teach) parts.push(`${o.teach} taught`);
- rows.push({ date: d, order: 3, cat: 'spa', delta, reason: `SPA (${ddmon(d)}): ${parts.join(' + ')} (validated) -> +${delta} SP.` });
- }
- }
- // Integrity penalty: one-time -% of current total SP (fraud takes precedence).
- const penRate = flags.fraud ? SPA_FRAUD_RATE : (flags.auditFail ? SPA_AUDIT_RATE : 0);
- let spaPenalty = 0;
- if (penRate > 0) {
- spaPenalty = Math.round(rows.reduce((a, r) => a + r.delta, 0) * penRate);
- if (spaPenalty > 0) rows.push({ date: TODAY, order: 9, cat: 'spa', delta: -spaPenalty,
- reason: `SPA (${ddmon(TODAY)}): ${flags.fraud ? 'fraud' : 'audit-failure'} penalty -${Math.round(penRate * 100)}% of current SP -> -${spaPenalty} SP.` });
- }
- // Query-answer rows: +5 per distinct peer query answered, one 'query' row per day.
- const qDates = queryByCanon.get(cand);
- if (qDates && qDates.length) {
- const qByDay = new Map();
- for (const d of qDates) qByDay.set(d, (qByDay.get(d) || 0) + 1);
- for (const [d, n] of qByDay) rows.push({ date: d, order: 4, cat: 'query', delta: n * QUERY_UNIT,
- reason: `Query answering (${ddmon(d)}): ${n} peer quer${n === 1 ? 'y' : 'ies'} answered -> +${n * QUERY_UNIT} SP.` });
- }
- // Preserved rows (manual commitment/admin SP + peer_faq) — fold in so they survive the wipe.
- for (const p of (preservedByCanon.get(cand) || [])) rows.push(p);
rows.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order);
let bal = 0; for (const r of rows) { bal += r.delta; ledger.push({ email: cand, name: info.name, ...r, balanceAfter: bal }); }
finalBal.set(cand, bal); nameByCanon.set(cand, info.name);
- if (spa || penRate > 0) spaSummary.push({ email: cand, learnValidated: spa ? spa.learn.length : 0, teachValidated: spa ? spa.teach.length : 0, learnCredited: spaLearnUsed, teachCredited: spaTeachUsed, auditFail: !!flags.auditFail, fraud: !!flags.fraud, penaltyApplied: spaPenalty });
}
for (const [e, o] of students) { if (STAFF.has(e) || matched.has(e) || emailToCanon.has(e)) continue; setAside.push({ email: e, name: o.name, rows: o.rows.length }); }
@@ -409,12 +227,6 @@ const dayLabel = (topic) => { const m = String(topic).match(/Day\s+([IVXLC0-9]+)
const docs = ledger.map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; });
let ins = 0; for (let i = 0; i < docs.length; i += 2000) { await Tx.insertMany(docs.slice(i, i + 2000), { ordered: false }); ins += Math.min(2000, docs.length - i); }
console.log(`APPLIED -> students upserted ${sBulk.length}, old txns deleted ${del}, new txns inserted ${ins}`);
- // SPA summary for the web-app SPA tab (display only; SP itself is in the ledger above).
- const Spa = sak.collection('spaprogresses');
- const spaOps = spaSummary.map((s) => ({ updateOne: { filter: { email: s.email },
- update: { $set: { ...s, activity: 'Activity 1: Linear Algebra', updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } }, upsert: true } }));
- for (let i = 0; i < spaOps.length; i += 1000) await Spa.bulkWrite(spaOps.slice(i, i + 1000), { ordered: false });
- console.log(`SPA -> spaprogresses upserted ${spaOps.length}`);
// RECONCILE: the new ledger is the COMPLETE source of truth — all current SP is
// rubric-generated (initial/attendance/poll only; no admin/discretionary txns
// exist). Any student NOT in the new ledger must be cleared so the leaderboard
diff --git a/pipeline/sp-rubric-build.js b/pipeline/sp-rubric-build.js
index 9a9c2d8..43e70f5 100644
--- a/pipeline/sp-rubric-build.js
+++ b/pipeline/sp-rubric-build.js
@@ -68,11 +68,6 @@ const STAFF = new Set([
'dled@iitrpr.ac.in', 'prakash.hegade@gmail.com',
'sudarshansudarshan@gmail.com', 'sudarshan@iitrpr.ac.in', 'rajankrsna@gmail.com',
]);
-// Categories THIS script owns (fully recomputed every run) vs. discretionary
-// award rows written by the app (admin manual + peer-review FAQ) that must be
-// PRESERVED across rebuilds — never wiped (the 3d self-wipe bug).
-const OWNED_CATS = ['initial', 'attendance', 'poll'];
-const PRESERVED_CATS = ['manual', 'peer_faq'];
const isMandatory = (t) => /stand|orientation/i.test(t) && !/breakout|weekend|nptel|special|support|non[- ]?mandatory/i.test(t);
const tier = (pct) => { pct = Math.min(100, pct); return pct >= 90 ? 10 : pct >= 75 ? 5 : pct >= 50 ? 3 : 0; };
@@ -126,23 +121,6 @@ async function participants(uuid) {
if (d) { internCanon.set(canon, emails); for (const e of emails) emailToCanon.set(e, canon); nameBy.set(canon, u.name || canon); }
}
- // 1b. Preserve discretionary awards (admin manual + peer-review FAQ). This
- // script fully recomputes initial/attendance/poll each run; award rows the
- // app writes into sakshi_spurti.sptransactions must survive the rebuild,
- // else the nightly delete-and-reinsert erases them (the 3d self-wipe). We
- // fold their deltas into each student's running balance and re-point
- // balanceAfter below, but never delete/recreate them — so awardedBy,
- // createdAt and other metadata are kept intact.
- const preservedByCanon = new Map(); // canon email -> [{ _id, date, order, cat, delta, reason }]
- for (const t of await sak.collection('sptransactions').find({ category: { $in: PRESERVED_CATS } }).toArray()) {
- const e = String(t.email || '').toLowerCase().trim(); if (!e) continue;
- const canon = emailToCanon.get(e) || e;
- const date = dstr(t.dateTime) || dstr(t.createdAt) || TODAY;
- const delta = Number(typeof t.appliedDelta === 'number' ? t.appliedDelta : t.deltaValue) || 0;
- if (!preservedByCanon.has(canon)) preservedByCanon.set(canon, []);
- preservedByCanon.get(canon).push({ _id: t._id, date, order: 3, cat: t.category, delta, reason: t.reason || '' });
- }
-
// 2. mandatory first-instance per day + official window
const meetings = await zoom.collection('meetings').find({ date: { $gte: START_DATE } }).sort({ date: 1, startTime: 1 }).toArray();
const byDate = {}; for (const m of meetings) (byDate[m.date] = byDate[m.date] || []).push(m);
@@ -207,7 +185,6 @@ async function participants(uuid) {
const best = new Map();
for (const e of info.emails) { const o = students.get(e); if (!o) continue; if (info.name === cand && o.name && !o.name.includes('@')) info.name = o.name; for (const r of o.rows) { if (r.date < info.start) continue; const k = r.date + '|' + r.cat; const cur = best.get(k); if (!cur || r.delta > cur.delta) best.set(k, r); } }
const rows = [{ date: info.start, order: 0, cat: 'initial', delta: 100, reason: `Base Spurti Points (100) credited on internship start date ${info.start}.` }, ...best.values()];
- for (const p of (preservedByCanon.get(cand) || [])) rows.push(p); // fold discretionary awards into the running balance
rows.sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order);
let bal = 0; for (const r of rows) { bal += r.delta; ledger.push({ email: cand, name: info.name, ...r, balanceAfter: bal }); }
finalBal.set(cand, bal); nameByCanon.set(cand, info.name);
@@ -248,17 +225,14 @@ async function participants(uuid) {
}
for (let i = 0; i < sBulk.length; i += 1000) await Students.bulkWrite(sBulk.slice(i, i + 1000), { ordered: false });
const idMap = new Map(); for (const s of await Students.find({ email: { $in: emails } }, { projection: { email: 1 } }).toArray()) idMap.set(s.email, s._id);
- // replace ONLY the categories this script owns; discretionary award rows are kept in place.
- let del = 0; for (let i = 0; i < emails.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: emails.slice(i, i + 500) }, category: { $in: OWNED_CATS } }); del += r.deletedCount; }
- const docs = ledger.filter((r) => !r._id).map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; });
+ // replace transactions
+ let del = 0; for (let i = 0; i < emails.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: emails.slice(i, i + 500) } }); del += r.deletedCount; }
+ const docs = ledger.map((r) => { const idx = r.reason.indexOf(': '); return { email: r.email, studentId: idMap.get(r.email), category: r.cat, sessionLabel: r.cat === 'initial' ? '' : (idx > 0 ? r.reason.slice(0, idx) : ''), deltaMode: 'absolute', deltaValue: r.delta, appliedDelta: r.delta, balanceAfter: r.balanceAfter, reason: r.reason, dateTime: new Date(r.date + (r.cat === 'initial' ? 'T00:00:00.000Z' : 'T09:00:00.000Z')), createdAt: new Date(), updatedAt: new Date() }; });
let ins = 0; for (let i = 0; i < docs.length; i += 2000) { await Tx.insertMany(docs.slice(i, i + 2000), { ordered: false }); ins += Math.min(2000, docs.length - i); }
- // re-point balanceAfter on preserved award rows (kept in place, metadata intact)
- const presOps = ledger.filter((r) => r._id).map((r) => ({ updateOne: { filter: { _id: r._id }, update: { $set: { balanceAfter: r.balanceAfter } } } }));
- let upd = 0; for (let i = 0; i < presOps.length; i += 1000) { const r = await Tx.bulkWrite(presOps.slice(i, i + 1000), { ordered: false }); upd += (r.modifiedCount || 0); }
- console.log(`APPLIED -> students upserted ${sBulk.length}, owned txns deleted ${del}, new txns inserted ${ins}, awards preserved/repointed ${presOps.length} (modified ${upd})`);
+ console.log(`APPLIED -> students upserted ${sBulk.length}, old txns deleted ${del}, new txns inserted ${ins}`);
// zero students who attended but are future-start or yet-to-onboard (meter not started)
if (zeroOut.length) {
- let zdel = 0; for (let i = 0; i < zeroOut.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: zeroOut.slice(i, i + 500) }, category: { $in: OWNED_CATS } }); zdel += r.deletedCount; }
+ let zdel = 0; for (let i = 0; i < zeroOut.length; i += 500) { const r = await Tx.deleteMany({ email: { $in: zeroOut.slice(i, i + 500) } }); zdel += r.deletedCount; }
for (let i = 0; i < zeroOut.length; i += 1000) await Students.bulkWrite(zeroOut.slice(i, i + 1000).map((email) => ({ updateOne: { filter: { email }, update: { $set: { totalSp: 0 } } } })), { ordered: false });
console.log(`ZEROED -> ${zeroOut.length} future-start/yet-to-onboard students (txns deleted ${zdel}, totalSp=0)`);
}
diff --git a/pipeline/spandan-poll-fetch.cjs b/pipeline/spandan-poll-fetch.cjs
deleted file mode 100644
index 164a600..0000000
--- a/pipeline/spandan-poll-fetch.cjs
+++ /dev/null
@@ -1,110 +0,0 @@
-'use strict';
-/**
- * spandan-poll-fetch.cjs
- *
- * Pulls ended poll sessions from the Spandan Research Session Export API and
- * mirrors the qualifying ones into `spandan_polls` (one doc per session, keyed
- * by roomId). This REPLACES the retired Zoom poll source for SP dates on/after
- * the cutoff. It is purely additive/non-destructive: it never touches
- * sptransactions, students, or any existing collection.
- *
- * Qualifying session = name matches /^Day N/ (the numbered classroom evening
- * sessions) AND date >= CUTOFF. Non-Day sessions (FDP events, "19th July
- * Evening Session" Sunday makeup) and pre-cutoff days are skipped.
- *
- * Incremental: stores the API's nextCursor in `spandan_sync` and passes it as
- * ?since= on the next run, so a scheduled job never misses or double-counts.
- *
- * Env (from .env): MONGO_URI, SPANDAN_RESEARCH_KEY
- * Flags:
- * FULL=1 ignore the stored cursor and re-pull from the beginning (backfill)
- * DRY=1 print what would be written; touch nothing
- *
- * Scoring itself lives in the rubric (sp-rubric-build-mirror.cjs), not here:
- * per day, top scorer = 100%, others = pointsEarned / dayTop * 100, banded
- * 10/5/3/0. This script just stores the raw session results + a convenience
- * `topPoints` for that computation.
- */
-const { MongoClient } = require('mongodb');
-require('dotenv').config();
-
-const BASE = 'https://spandan.fun/spandan/api/research/sessions';
-const CUTOFF = '2026-07-16'; // first full-cohort evening session (Day 53)
-const DAY_RE = /^Day\s+(\d+)\b/i; // only numbered "Day N ..." sessions count
-const PAGE = 1000;
-
-const { MONGO_URI, SPANDAN_RESEARCH_KEY } = process.env;
-const FULL = process.env.FULL === '1';
-const DRY = process.env.DRY === '1';
-
-const lc = (s) => String(s || '').toLowerCase().trim();
-
-async function fetchPage(since) {
- const url = new URL(BASE);
- url.searchParams.set('preset', 'evening');
- url.searchParams.set('limit', String(PAGE));
- if (since) url.searchParams.set('since', since);
- const r = await fetch(url, { headers: { 'X-Research-Key': SPANDAN_RESEARCH_KEY } });
- if (!r.ok) throw new Error(`Spandan API ${r.status}: ${await r.text().catch(() => '')}`);
- return r.json();
-}
-
-(async () => {
- if (!MONGO_URI) { console.error('missing MONGO_URI'); process.exit(1); }
- if (!SPANDAN_RESEARCH_KEY) { console.error('missing SPANDAN_RESEARCH_KEY'); process.exit(1); }
-
- const client = await MongoClient.connect(MONGO_URI);
- const db = client.db(); // db name comes from the URI
- const sync = db.collection('spandan_sync');
- const polls = db.collection('spandan_polls');
-
- let since = null;
- if (!FULL) {
- const cur = await sync.findOne({ _id: 'cursor' });
- since = cur ? cur.value : null;
- }
- console.log(`spandan-poll-fetch: ${FULL ? 'FULL backfill' : since ? `since ${since}` : 'first run (all)'}${DRY ? ' [DRY]' : ''}`);
-
- let kept = 0, seen = 0, lastCursor = since;
- while (true) {
- const data = await fetchPage(since);
- seen += data.count;
- for (const s of data.sessions) {
- const m = DAY_RE.exec(s.name || '');
- if (!m) continue; // not a numbered Day session
- if (s.date < CUTOFF) continue; // pre-switchover
- const students = (s.students || []).map((x) => ({
- email: lc(x.studentEmail),
- pointsEarned: x.pointsEarned || 0,
- questionsAnswered: x.questionsAnswered || 0,
- })).filter((x) => x.email);
- const topPoints = students.reduce((mx, x) => Math.max(mx, x.pointsEarned), 0);
- const doc = {
- roomId: s.roomId,
- name: s.name,
- dayNumber: Number(m[1]),
- date: s.date,
- endedAt: new Date(s.endedAt),
- totalQuestions: s.totalQuestions || 0,
- maxPoints: s.maxPoints || 0,
- topPoints,
- studentCount: students.length,
- students,
- updatedAt: new Date(),
- };
- if (DRY) {
- console.log(` KEEP ${doc.date} Day ${doc.dayNumber} | Q${doc.totalQuestions} max${doc.maxPoints} top${topPoints} | ${doc.studentCount} students`);
- } else {
- await polls.updateOne({ roomId: doc.roomId }, { $set: doc, $setOnInsert: { createdAt: new Date() } }, { upsert: true });
- }
- kept++;
- }
- lastCursor = data.nextCursor || lastCursor;
- if (!DRY && lastCursor) await sync.updateOne({ _id: 'cursor' }, { $set: { value: lastCursor, updatedAt: new Date() } }, { upsert: true });
- if (data.count < PAGE) break; // last page
- since = data.nextCursor;
- }
-
- console.log(`Done. scanned ${seen} evening session(s), kept ${kept} Day-N session(s) >= ${CUTOFF}. cursor=${lastCursor}${DRY ? ' (not saved)' : ''}`);
- await client.close();
-})().catch((e) => { console.error('FATAL', e); process.exit(1); });
diff --git a/pipeline/sync-poll-records.js b/pipeline/sync-poll-records.js
index 54f7daa..46e07dc 100644
--- a/pipeline/sync-poll-records.js
+++ b/pipeline/sync-poll-records.js
@@ -21,18 +21,6 @@ const POLL_RE = /answered (\d+) of (\d+) poll questions/;
const students = await db.collection('students').find({}, { projection: { _id: 1, email: 1 } }).toArray();
const studentById = new Map(students.map(s => [s.email.toLowerCase().trim(), s._id]));
- // Spandan-era poll counts (>= cutoff): the reason is short and correctness-based,
- // so it doesn't carry "answered X of Y". Take participation straight from the
- // spandan_polls mirror, joined to each poll txn by (email, date).
- const CUTOFF = process.env.SPANDAN_CUTOFF || '2026-07-16';
- const spByEmailDate = new Map();
- for (const sp of await db.collection('spandan_polls').find({ date: { $gte: CUTOFF } }).toArray()) {
- for (const x of sp.students || []) {
- const e = String(x.email || '').toLowerCase().trim(); if (!e) continue;
- spByEmailDate.set(e + '|' + sp.date, { attempted: x.questionsAnswered || 0, total: sp.totalQuestions || 0 });
- }
- }
-
const txns = await db.collection('sptransactions')
.find({ category: 'poll' })
.toArray();
@@ -44,16 +32,9 @@ const POLL_RE = /answered (\d+) of (\d+) poll questions/;
const sessionLabel = tx.sessionLabel || '';
if (!sessionLabel) { skipped++; continue; }
- const date = tx.dateTime ? new Date(tx.dateTime).toISOString().slice(0, 10) : '';
- const spd = spByEmailDate.get(email + '|' + date);
- let attemptedQuestions, totalQuestions;
- if (spd) {
- attemptedQuestions = spd.attempted; totalQuestions = spd.total; // Spandan participation
- } else {
- const m = POLL_RE.exec(tx.reason || ''); // legacy Zoom reason
- attemptedQuestions = m ? Number(m[1]) : 0;
- totalQuestions = m ? Number(m[2]) : 0;
- }
+ const m = POLL_RE.exec(tx.reason || '');
+ const attemptedQuestions = m ? Number(m[1]) : 0;
+ const totalQuestions = m ? Number(m[2]) : 0;
const missedQuestions = Math.max(0, totalQuestions - attemptedQuestions);
const studentId = studentById.get(email) || null;
diff --git a/server/engagement/classifyBand.js b/server/engagement/classifyBand.js
new file mode 100644
index 0000000..ab62246
--- /dev/null
+++ b/server/engagement/classifyBand.js
@@ -0,0 +1,74 @@
+import { ENGAGEMENT_BANDS, ENGAGEMENT_THRESHOLDS, ROLLING_WINDOW_SIZE } from './config.js';
+
+function avg(arr, key) {
+ const vals = arr.map(s => s[key]).filter(v => v !== null && v !== undefined);
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
+}
+
+function isDeclining(window) {
+ if (window.length < 2) return false;
+ const att = window.map(s => s.attendancePct).filter(v => v !== null);
+ const sp = window.map(s => s.spDelta);
+ if (att.length >= 2 && att[att.length - 1] < att[0]) return true;
+ if (sp.length >= 2 && sp[sp.length - 1] < sp[0]) return true;
+ return false;
+}
+
+function isImproving(window) {
+ if (window.length < 2) return false;
+ const att = window.map(s => s.attendancePct).filter(v => v !== null);
+ const sp = window.map(s => s.spDelta);
+ if (att.length >= 2 && att[att.length - 1] > att[0]) return true;
+ if (sp.length >= 2 && sp[sp.length - 1] > sp[0]) return true;
+ return false;
+}
+
+function hasData(window) {
+ return window.length > 0 && window.some(s => s.attendancePct !== null);
+}
+
+export function classifyBand(currentWindow, previousWindow = []) {
+ if (!hasData(currentWindow)) {
+ return { band: ENGAGEMENT_BANDS.ACTIVE, reason: 'No attendance data available yet' };
+ }
+
+ const avgAtt = avg(currentWindow, 'attendancePct');
+ const avgSp = avg(currentWindow, 'spDelta');
+ const declining = isDeclining(currentWindow);
+ const improving = isImproving(currentWindow);
+
+ if (previousWindow.length > 0) {
+ const prev = classifyBand(previousWindow);
+ if (prev.band === ENGAGEMENT_BANDS.SLOWING_DOWN && improving) {
+ return {
+ band: ENGAGEMENT_BANDS.RECOVERY,
+ reason: `Attendance improved to ${Math.round(avgAtt)}%, SP trend reversing — recovered from Slowing Down`,
+ stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 }
+ };
+ }
+ }
+
+ if (avgAtt >= ENGAGEMENT_THRESHOLDS.excellent.minAttendancePct && avgSp >= ENGAGEMENT_THRESHOLDS.excellent.minSpPerSession) {
+ return {
+ band: ENGAGEMENT_BANDS.EXCELLENT,
+ reason: `Avg attendance ${Math.round(avgAtt)}% (≥90%), avg SP +${Math.round(avgSp)} per session`,
+ stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 }
+ };
+ }
+
+ if (avgAtt >= ENGAGEMENT_THRESHOLDS.active.minAttendancePct && avgSp >= ENGAGEMENT_THRESHOLDS.active.minSpPerSession) {
+ const trendNote = declining ? ' but showing early decline signs' : ' — steady engagement';
+ return {
+ band: ENGAGEMENT_BANDS.ACTIVE,
+ reason: `Avg attendance ${Math.round(avgAtt)}% (≥75%), avg SP +${Math.round(avgSp)} per session${trendNote}`,
+ stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 }
+ };
+ }
+
+ const declineNote = declining ? 'trend declining' : 'below active thresholds';
+ return {
+ band: ENGAGEMENT_BANDS.SLOWING_DOWN,
+ reason: `Attendance at ${Math.round(avgAtt)}%, SP +${Math.round(avgSp)}/session — ${declineNote}`,
+ stats: { avgAttendancePct: Math.round(avgAtt), avgSpPerSession: Math.round(avgSp * 10) / 10 }
+ };
+}
diff --git a/server/engagement/config.js b/server/engagement/config.js
new file mode 100644
index 0000000..269e9f1
--- /dev/null
+++ b/server/engagement/config.js
@@ -0,0 +1,46 @@
+export const ROLLING_WINDOW_SIZE = 3;
+
+export const ATTENDANCE_BANDS = [
+ { label: 'excellent', minPct: 90 },
+ { label: 'good', minPct: 75 },
+ { label: 'fair', minPct: 50 },
+ { label: 'low', minPct: 0 }
+];
+
+export const SP_DELTA_BANDS = [
+ { label: 'strong', minDelta: 10 },
+ { label: 'moderate', minDelta: 5 },
+ { label: 'slight', minDelta: 3 },
+ { label: 'none', minDelta: 0 }
+];
+
+export const ENGAGEMENT_BANDS = {
+ EXCELLENT: 'Excellent',
+ ACTIVE: 'Active',
+ SLOWING_DOWN: 'Slowing Down',
+ RECOVERY: 'Recovery'
+};
+
+export const ENGAGEMENT_THRESHOLDS = {
+ excellent: {
+ minAttendancePct: 90,
+ minSpPerSession: 8,
+ description: 'High attendance and strong SP gain across the window'
+ },
+ active: {
+ minAttendancePct: 75,
+ minSpPerSession: 3,
+ description: 'Consistent attendance and moderate SP gain'
+ },
+ slowingDown: {
+ maxAttendancePct: 74,
+ maxSpPerSession: 2,
+ decliningTrendRequired: true,
+ description: 'Declining attendance or SP trend over the window'
+ },
+ recovery: {
+ minAttendancePct: 75,
+ priorBandRequired: 'Slowing Down',
+ description: 'Improved from a prior Slowing Down trend'
+ }
+};
diff --git a/server/engagement/fetchData.js b/server/engagement/fetchData.js
new file mode 100644
index 0000000..3370d97
--- /dev/null
+++ b/server/engagement/fetchData.js
@@ -0,0 +1,37 @@
+import { ROLLING_WINDOW_SIZE } from './config.js';
+import AttendanceRecord from '../models/AttendanceRecord.js';
+import SPTransaction from '../models/SPTransaction.js';
+import Session from '../models/Session.js';
+
+export async function fetchStudentEngagementData(email) {
+ const [sessions, attendanceRecords, spTransactions] = await Promise.all([
+ Session.find().sort({ endDateTime: 1 }).lean(),
+ AttendanceRecord.find({ email }).lean(),
+ SPTransaction.find({ email, category: { $ne: 'initial' } }).sort({ dateTime: 1 }).lean()
+ ]);
+
+ const attendanceByLabel = {};
+ for (const rec of attendanceRecords) {
+ attendanceByLabel[rec.sessionLabel] = rec.attendancePercentage;
+ }
+
+ const spByLabel = {};
+ for (const txn of spTransactions) {
+ if (!spByLabel[txn.sessionLabel]) spByLabel[txn.sessionLabel] = 0;
+ spByLabel[txn.sessionLabel] += txn.appliedDelta;
+ }
+
+ const windowed = sessions.map(s => ({
+ label: s.label,
+ date: s.endDateTime,
+ totalMinutes: s.totalMinutes,
+ attendancePct: attendanceByLabel[s.label] ?? null,
+ spDelta: spByLabel[s.label] ?? 0
+ }));
+
+ const n = ROLLING_WINDOW_SIZE;
+ const current = windowed.slice(-n);
+ const previous = windowed.length > n ? windowed.slice(-n * 2, -n) : [];
+
+ return { current, previous, all: windowed };
+}
diff --git a/server/engagement/seed-demo.js b/server/engagement/seed-demo.js
new file mode 100644
index 0000000..b0abdc8
--- /dev/null
+++ b/server/engagement/seed-demo.js
@@ -0,0 +1,125 @@
+import mongoose from 'mongoose';
+import { MONGO_URI } from '../config.js';
+import Student from '../models/Student.js';
+import Session from '../models/Session.js';
+import AttendanceRecord from '../models/AttendanceRecord.js';
+import SPTransaction from '../models/SPTransaction.js';
+
+const students = [
+ {
+ name: 'Ananya Sharma', email: 'ananya@test.com',
+ status: 'active', totalSp: 180, sessions: [
+ { label: 'Day 1 (15 May)', attendancePct: 98, spDelta: 10 },
+ { label: 'Day 2 (16 May)', attendancePct: 95, spDelta: 10 },
+ { label: 'Day 3 (19 May)', attendancePct: 92, spDelta: 10 },
+ { label: 'Day 4 (20 May)', attendancePct: 96, spDelta: 10 },
+ { label: 'Day 5 (21 May)', attendancePct: 91, spDelta: 10 },
+ { label: 'Day 6 (22 May)', attendancePct: 94, spDelta: 10 }
+ ]
+ },
+ {
+ name: 'Rahul Verma', email: 'rahul@test.com',
+ status: 'active', totalSp: 140, sessions: [
+ { label: 'Day 1 (15 May)', attendancePct: 85, spDelta: 5 },
+ { label: 'Day 2 (16 May)', attendancePct: 80, spDelta: 5 },
+ { label: 'Day 3 (19 May)', attendancePct: 78, spDelta: 5 },
+ { label: 'Day 4 (20 May)', attendancePct: 82, spDelta: 5 },
+ { label: 'Day 5 (21 May)', attendancePct: 76, spDelta: 5 },
+ { label: 'Day 6 (22 May)', attendancePct: 79, spDelta: 5 }
+ ]
+ },
+ {
+ name: 'Priya Patel', email: 'priya@test.com',
+ status: 'active', totalSp: 118, sessions: [
+ { label: 'Day 1 (15 May)', attendancePct: 92, spDelta: 10 },
+ { label: 'Day 2 (16 May)', attendancePct: 85, spDelta: 5 },
+ { label: 'Day 3 (19 May)', attendancePct: 70, spDelta: 3 },
+ { label: 'Day 4 (20 May)', attendancePct: 55, spDelta: 3 },
+ { label: 'Day 5 (21 May)', attendancePct: 40, spDelta: 0 },
+ { label: 'Day 6 (22 May)', attendancePct: 25, spDelta: 0 }
+ ]
+ },
+ {
+ name: 'Arjun Nair', email: 'arjun@test.com',
+ status: 'active', totalSp: 126, sessions: [
+ { label: 'Day 1 (15 May)', attendancePct: 40, spDelta: 0 },
+ { label: 'Day 2 (16 May)', attendancePct: 50, spDelta: 3 },
+ { label: 'Day 3 (19 May)', attendancePct: 45, spDelta: 0 },
+ { label: 'Day 4 (20 May)', attendancePct: 78, spDelta: 5 },
+ { label: 'Day 5 (21 May)', attendancePct: 85, spDelta: 5 },
+ { label: 'Day 6 (22 May)', attendancePct: 92, spDelta: 10 }
+ ]
+ }
+];
+
+const sessionDefs = [
+ { label: 'Day 1 (15 May)', date: new Date('2026-05-15'), endDateTime: new Date('2026-05-15T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 2 (16 May)', date: new Date('2026-05-16'), endDateTime: new Date('2026-05-16T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 3 (19 May)', date: new Date('2026-05-19'), endDateTime: new Date('2026-05-19T12:00Z'), totalMinutes: 90 },
+ { label: 'Day 4 (20 May)', date: new Date('2026-05-20'), endDateTime: new Date('2026-05-20T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 5 (21 May)', date: new Date('2026-05-21'), endDateTime: new Date('2026-05-21T12:00Z'), totalMinutes: 80 },
+ { label: 'Day 6 (22 May)', date: new Date('2026-05-22'), endDateTime: new Date('2026-05-22T12:00Z'), totalMinutes: 240 }
+];
+
+async function seed() {
+ await mongoose.connect(MONGO_URI);
+ console.log('Connected to MongoDB');
+
+ await Promise.all([
+ Student.deleteMany({}),
+ Session.deleteMany({}),
+ AttendanceRecord.deleteMany({}),
+ SPTransaction.deleteMany({})
+ ]);
+ console.log('Cleared existing data');
+
+ const sessions = await Session.insertMany(sessionDefs);
+ console.log(`Inserted ${sessions.length} sessions`);
+
+ for (const studentData of students) {
+ const student = await Student.create({
+ name: studentData.name,
+ email: studentData.email,
+ internshipStartDate: new Date('2026-05-15'),
+ internshipEndDate: new Date('2026-08-15'),
+ status: 'active',
+ totalSp: studentData.totalSp
+ });
+
+ let balance = 100;
+ await SPTransaction.create({
+ email: student.email, studentId: student._id,
+ category: 'initial', sessionLabel: '',
+ deltaMode: 'absolute', deltaValue: 100, appliedDelta: 100,
+ balanceAfter: 100, reason: 'Initial SP credit',
+ dateTime: new Date('2026-05-15')
+ });
+
+ for (const sess of studentData.sessions) {
+ balance += sess.spDelta;
+ const attSess = sessions.find(s => s.label === sess.label);
+ await AttendanceRecord.create({
+ email: student.email, studentId: student._id,
+ sessionLabel: sess.label,
+ attendedMinutes: Math.round(sess.attendancePct / 100 * (attSess?.totalMinutes || 120)),
+ totalSessionMinutes: attSess?.totalMinutes || 120,
+ attendancePercentage: sess.attendancePct,
+ qualified: sess.attendancePct >= 75
+ });
+ await SPTransaction.create({
+ email: student.email, studentId: student._id,
+ category: 'attendance', sessionLabel: sess.label,
+ deltaMode: 'absolute', deltaValue: sess.spDelta, appliedDelta: sess.spDelta,
+ balanceAfter: balance, reason: `Attendance SP for ${sess.label}`,
+ dateTime: attSess?.endDateTime || new Date('2026-05-15')
+ });
+ }
+ console.log(` ${studentData.name.padEnd(16)} (${studentData.email}) — totalSp: ${studentData.totalSp}, sessions: ${studentData.sessions.length}`);
+ }
+
+ console.log('\nDone! 4 demo students seeded.');
+ console.log('Search at http://localhost:5290 by name or email.');
+ await mongoose.disconnect();
+}
+
+seed().catch(err => { console.error(err); process.exit(1); });
diff --git a/server/engagement/test-classifier.js b/server/engagement/test-classifier.js
new file mode 100644
index 0000000..b5f444c
--- /dev/null
+++ b/server/engagement/test-classifier.js
@@ -0,0 +1,46 @@
+import mongoose from 'mongoose';
+import { MONGO_URI } from '../config.js';
+import { fetchStudentEngagementData } from './fetchData.js';
+import { classifyBand } from './classifyBand.js';
+
+const emails = [
+ 'ananya@test.com',
+ 'rahul@test.com',
+ 'priya@test.com',
+ 'arjun@test.com'
+];
+
+function printWindow(data) {
+ for (const s of data) {
+ const att = s.attendancePct !== null ? String(s.attendancePct).padStart(5) + '%' : ' N/A ';
+ const sp = String(s.spDelta).padStart(3);
+ console.log(` ${s.label.padEnd(20)}| ${att} | ${sp}`);
+ }
+}
+
+async function main() {
+ await mongoose.connect(MONGO_URI);
+
+ for (const email of emails) {
+ const data = await fetchStudentEngagementData(email);
+ const result = classifyBand(data.current, data.previous);
+
+ console.log(`\n${'='.repeat(60)}`);
+ console.log(`${email}`);
+ console.log(`${'='.repeat(60)}`);
+ console.log(`\nPrevious window (${data.previous.length} sessions):`);
+ printWindow(data.previous);
+ console.log(`\nCurrent window (${data.current.length} sessions):`);
+ printWindow(data.current);
+ console.log(`\n → Band: ${result.band}`);
+ console.log(` → Reason: ${result.reason}`);
+ if (result.stats) {
+ console.log(` → Avg Attendance: ${result.stats.avgAttendancePct}%`);
+ console.log(` → Avg SP/session: ${result.stats.avgSpPerSession}`);
+ }
+ }
+
+ await mongoose.disconnect();
+}
+
+main().catch(err => { console.error(err); process.exit(1); });
diff --git a/server/engagement/test-fetch.js b/server/engagement/test-fetch.js
new file mode 100644
index 0000000..2cd0330
--- /dev/null
+++ b/server/engagement/test-fetch.js
@@ -0,0 +1,67 @@
+import { ROLLING_WINDOW_SIZE } from './config.js';
+
+function simulateFetch(email) {
+ const sessions = [
+ { label: 'Day 1 (15 May)', date: new Date('2026-05-15T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 2 (16 May)', date: new Date('2026-05-16T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 3 (19 May)', date: new Date('2026-05-19T12:00Z'), totalMinutes: 90 },
+ { label: 'Day 4 (20 May)', date: new Date('2026-05-20T12:00Z'), totalMinutes: 120 },
+ { label: 'Day 5 (21 May)', date: new Date('2026-05-21T12:00Z'), totalMinutes: 80 },
+ { label: 'Day 6 (22 May)', date: new Date('2026-05-22T12:00Z'), totalMinutes: 240 }
+ ];
+
+ const attendance = {
+ 'Day 1 (15 May)': 95,
+ 'Day 2 (16 May)': 88,
+ 'Day 3 (19 May)': 72,
+ 'Day 4 (20 May)': 65,
+ 'Day 5 (21 May)': 45,
+ 'Day 6 (22 May)': 30
+ };
+
+ const spDeltas = {
+ 'Day 1 (15 May)': 10,
+ 'Day 2 (16 May)': 10,
+ 'Day 3 (19 May)': 5,
+ 'Day 4 (20 May)': 5,
+ 'Day 5 (21 May)': 3,
+ 'Day 6 (22 May)': 0
+ };
+
+ const windowed = sessions.map(s => ({
+ label: s.label,
+ date: s.date,
+ totalMinutes: s.totalMinutes,
+ attendancePct: attendance[s.label] ?? null,
+ spDelta: spDeltas[s.label] ?? 0
+ }));
+
+ const n = ROLLING_WINDOW_SIZE;
+ const current = windowed.slice(-n);
+ const previous = windowed.length > n ? windowed.slice(-n * 2, -n) : [];
+
+ return { current, previous, all: windowed };
+}
+
+function printWindow(label, data) {
+ console.log(`\n=== ${label} (${data.length} sessions, window size=${ROLLING_WINDOW_SIZE}) ===`);
+ console.log('Session | Attend % | SP Delta');
+ console.log('-------------------------|----------|---------');
+ for (const s of data) {
+ const att = s.attendancePct !== null ? String(s.attendancePct).padStart(5) + '%' : ' N/A ';
+ const sp = String(s.spDelta).padStart(3);
+ console.log(`${s.label.padEnd(25)}| ${att} | ${sp}`);
+ }
+}
+
+console.log('Fetching engagement data for student@example.com...\n');
+
+const data = simulateFetch('student@example.com');
+
+printWindow('PREVIOUS WINDOW', data.previous);
+printWindow('CURRENT WINDOW', data.current);
+
+console.log('\n--- Summary ---');
+console.log(`Total sessions available: ${data.all.length}`);
+console.log(`Previous window covers: ${data.previous.map(s => s.label).join(', ') || '(none)'}`);
+console.log(`Current window covers: ${data.current.map(s => s.label).join(', ')}`);
diff --git a/server/engagement/verify-chunk8.js b/server/engagement/verify-chunk8.js
new file mode 100644
index 0000000..d514432
--- /dev/null
+++ b/server/engagement/verify-chunk8.js
@@ -0,0 +1,40 @@
+import mongoose from 'mongoose';
+import { MONGO_URI } from '../config.js';
+import { fetchStudentEngagementData } from './fetchData.js';
+import { classifyBand } from './classifyBand.js';
+import Student from '../models/Student.js';
+
+async function verify() {
+ await mongoose.connect(MONGO_URI);
+ const students = await Student.find({ status: 'active' }).lean();
+
+ console.log('=== VERIFICATION: Engagement Classification ===\n');
+
+ let ok = 0;
+ for (const s of students) {
+ const data = await fetchStudentEngagementData(s.email);
+ const r1 = classifyBand(data.current, data.previous);
+ const r2 = classifyBand(data.current, data.previous);
+ const idempotent = r1.band === r2.band;
+
+ console.log(` ${s.email.padEnd(22)} \u2192 ${r1.band.padEnd(14)} | idempotent: ${idempotent ? 'YES' : 'NO'}`);
+ console.log(` Reason: ${r1.reason}`);
+ console.log(` Avg Att: ${r1.stats?.avgAttendancePct || 'N/A'}%, Avg SP/session: ${r1.stats?.avgSpPerSession || 'N/A'}`);
+ if (idempotent) ok++;
+ }
+
+ console.log(`\n \u2713 Idempotent: ${ok}/${students.length}`);
+
+ // Leaderboard check — confirm no side effects
+ const lb = await Student.find({ status: 'active' }).sort({ totalSp: -1, name: 1 }).lean();
+ console.log('\n=== LEADERBOARD (unchanged by engagement calls) ===');
+ console.log(' Rank | Name | Total SP');
+ console.log(' -----|-----------------------|---------');
+ lb.forEach((s, i) => {
+ console.log(` ${String(i + 1).padStart(4)} | ${s.name.padEnd(22)} | ${s.totalSp}`);
+ });
+
+ await mongoose.disconnect();
+}
+
+verify().catch(err => { console.error(err); process.exit(1); });
diff --git a/server/models/Commitment.js b/server/models/Commitment.js
deleted file mode 100644
index 950bb91..0000000
--- a/server/models/Commitment.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import mongoose from 'mongoose';
-
-// A commitment (formerly VibeBet) — a stake-a-goal pledge in ANY internship phase.
-// One shared collection; `type` selects the phase and which fields apply. One active
-// commitment per (email, type). Two economic modes:
-// - debited (ViBe): the stake is debited at placement, returned ×multiplier on a HIT.
-// - keep (Standup): the stake is NOT debited; a HIT pays a +stake×mult bonus on top
-// of the attendance points earned that week, a MISS charges −0.5×stake×mult.
-const commitmentSchema = new mongoose.Schema({
- email: { type: String, lowercase: true, trim: true, required: true, index: true },
- type: { type: String, enum: ['vibe', 'standup'], required: true, index: true },
-
- // shared economics
- stake: { type: Number, required: true }, // 20 / 50 (standup tiers) or 50–200 (vibe)
- multiplier: { type: Number, required: true }, // 2 | 3 | 4
- potentialWin: { type: Number, required: true }, // stake * multiplier
- potentialLoss: { type: Number, required: true }, // 0.5 * stake * multiplier
- reserved: { type: Number, default: 0 }, // SP reserved while active (vibe = loss; standup = 0)
- debited: { type: Boolean, default: false }, // was the stake debited at placement (vibe true)
- deadline: { type: Date, required: true },
- status: { type: String, enum: ['active', 'won', 'lost'], default: 'active', index: true },
- resultDelta: { type: Number, default: 0 },
- settledAt: { type: Date, default: null },
- label: { type: String, default: '' }, // human summary (for history)
-
- // ViBe-specific
- course: { type: String, default: '' }, // course key
- goalPct: { type: Number, default: 0 }, // raise completion by this many %
- baselinePct: { type: Number, default: 0 }, // completion % at commit time
-
- // Standup-specific
- tier: { type: String, default: '' }, // '81-90' | '91-100'
- tierFloor: { type: Number, default: 0 }, // min average attendance % to hit (81 | 91)
- sessionsTarget: { type: Number, default: 0 }, // sessions to attend this week (full week Y)
- weekStart: { type: Date, default: null },
- weekEnd: { type: Date, default: null }
-}, { timestamps: true });
-
-commitmentSchema.index({ email: 1, type: 1, status: 1 });
-
-export default mongoose.model('Commitment', commitmentSchema);
diff --git a/server/models/JourneyPlan.js b/server/models/JourneyPlan.js
deleted file mode 100644
index e6a1aae..0000000
--- a/server/models/JourneyPlan.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import mongoose from 'mongoose';
-
-// A student's self-declared internship plan: target dates to finish each phase.
-// Soft goals (no SP staked here — that lives in the commitment/ViBe tab). Hitting
-// a planned date can later award a completion bonus. One plan per student.
-const journeyPlanSchema = new mongoose.Schema({
- email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true },
- standupBy: { type: Date, default: null }, // reach 3600 cumulative Zoom minutes by
- vibeBy: { type: Date, default: null }, // finish all 3 ViBe courses by
- spaBy: { type: Date, default: null }, // solve all 53 SPA problems by
- projectBy: { type: Date, default: null } // first / target project PR by
-}, { timestamps: true });
-
-export default mongoose.model('JourneyPlan', journeyPlanSchema);
diff --git a/server/models/JourneyProgress.js b/server/models/JourneyProgress.js
deleted file mode 100644
index ea49c64..0000000
--- a/server/models/JourneyProgress.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import mongoose from 'mongoose';
-
-// Per-student SPA + Projects progress. PLACEHOLDER source: seeded with dummy values
-// locally so the My-Journey cards have numbers. In production these fields will be
-// refreshed from Samagama (SPA solver counts / SPA points; project PRs raised &
-// merged). The SP-award rule for these two phases is still TBD (decided once the
-// real Samagama data shape is known) — that is why sp is not computed here yet.
-const journeyProgressSchema = new mongoose.Schema({
- email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true },
- // SPA — Matrix Mystics (53 problems)
- spaSolved: { type: Number, default: 0 },
- spaTotal: { type: Number, default: 53 },
- spaPoints: { type: Number, default: 0 }, // existing "SPA points" (separate leaderboard currency)
- // Projects — PRs (from Samagama)
- prsRaised: { type: Number, default: 0 },
- prsMerged: { type: Number, default: 0 }
-}, { timestamps: true });
-
-export default mongoose.model('JourneyProgress', journeyProgressSchema);
diff --git a/server/models/SPTransaction.js b/server/models/SPTransaction.js
index 4bda691..0b4dd92 100644
--- a/server/models/SPTransaction.js
+++ b/server/models/SPTransaction.js
@@ -6,7 +6,7 @@ const spTransactionSchema = new mongoose.Schema({
category: {
type: String,
required: true,
- enum: ['initial', 'attendance', 'poll', 'manual', 'peer_faq', 'spa', 'query'],
+ enum: ['initial', 'attendance', 'poll', 'manual'],
index: true
},
sessionLabel: { type: String, default: '', index: true },
diff --git a/server/models/SpaProgress.js b/server/models/SpaProgress.js
deleted file mode 100644
index edd2199..0000000
--- a/server/models/SpaProgress.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import mongoose from 'mongoose';
-
-// Per-student SPA (peer-teaching endorsement) progress for the SPA→SP module.
-// Locally seeded with DUMMY values; in production these counts come from the
-// `act_spa_*` collections in `sakshi_spurti`:
-// learnValidated = # endorsements RECEIVED with status in {approved, audit_passed}
-// teachValidated = # endorsements GIVEN with status in {approved, audit_passed}
-// auditFail = has a genuine audit_failure_* penalty (viva-failed on review)
-// fraud = genuine fraud AFTER netting teacher_fraud_penalty vs
-// fraud_penalty_reversal (test/reversed rows are NOT fraud)
-//
-// SP is credited AUTOMATICALLY as activity arrives — +5 per validated question
-// learned, +8 per validated peer taught — via syncSpaSp(). The *Credited fields
-// are watermarks of how many events have already been posted to the SP ledger,
-// so re-syncs only post the delta (idempotent). No student "claim" step.
-const spaProgressSchema = new mongoose.Schema({
- email: { type: String, lowercase: true, trim: true, required: true, unique: true, index: true },
- activity: { type: String, default: 'Activity 1: Linear Algebra' },
- learnValidated: { type: Number, default: 0 }, // good endorsements received (from mirror)
- teachValidated: { type: Number, default: 0 }, // good endorsements given (from mirror)
- learnCredited: { type: Number, default: 0 }, // learns already posted to the ledger
- teachCredited: { type: Number, default: 0 }, // teaches already posted to the ledger
- auditFail: { type: Boolean, default: false }, // → one-time -20% of current SP
- fraud: { type: Boolean, default: false }, // → one-time -50% of current SP
- auditPenaltyApplied: { type: Boolean, default: false },
- fraudPenaltyApplied: { type: Boolean, default: false },
- penaltyApplied: { type: Number, default: 0 }, // total SP removed by integrity penalties
- penaltyAt: { type: Date, default: null }
-}, { timestamps: true });
-
-export default mongoose.model('SpaProgress', spaProgressSchema);
diff --git a/server/models/Student.js b/server/models/Student.js
index d670d1d..1b3e7f2 100644
--- a/server/models/Student.js
+++ b/server/models/Student.js
@@ -24,10 +24,7 @@ const studentSchema = new mongoose.Schema({
// Second perception pop-up ("poll2") — same mechanism as surveyCompleted, but an
// independent flag so it never disturbs the first survey's completion state.
poll2Completed: { type: Boolean, default: false, index: true },
- poll2CompletedAt: { type: Date, default: null },
- // Third pop-up ("poll3") — dashboard usability survey. Same mechanism, own flag.
- poll3Completed: { type: Boolean, default: false, index: true },
- poll3CompletedAt: { type: Date, default: null }
+ poll2CompletedAt: { type: Date, default: null }
}, { timestamps: true });
studentSchema.index({ name: 'text', email: 'text', alternateEmail: 'text' });
diff --git a/server/models/TrajectorySnapshot.js b/server/models/TrajectorySnapshot.js
deleted file mode 100644
index 2cac470..0000000
--- a/server/models/TrajectorySnapshot.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import mongoose from 'mongoose';
-
-// Precomputed average SP trajectories (cumulative SP by week-since-join), so the
-// student trajectory chart can show cohort + onboarding-group reference lines
-// without aggregating every student's ledger on each page load. One 'latest' doc,
-// refreshed by server/scripts/buildTrajectories.js (wire into the analytics cron in prod).
-const pointSchema = new mongoose.Schema({ week: Number, sp: Number, n: Number }, { _id: false });
-
-const trajectorySnapshotSchema = new mongoose.Schema({
- key: { type: String, default: 'latest', unique: true },
- weeks: { type: Number, default: 10 },
- cohort: { type: [pointSchema], default: [] }, // mean cumulative SP by week, all non-excused students
- groups: { type: Object, default: {} }, // {
: [{week,sp,n}] }
- groupLabels: { type: Object, default: {} }, // { : "1 May to 15 May" }
- computedAt: { type: Date }
-}, { minimize: false });
-
-export default mongoose.model('TrajectorySnapshot', trajectorySnapshotSchema);
diff --git a/server/models/VibeProgress.js b/server/models/VibeProgress.js
deleted file mode 100644
index 187d90c..0000000
--- a/server/models/VibeProgress.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import mongoose from 'mongoose';
-
-// Per-student ViBe course progress. In production this is refreshed from the ViBe
-// leaderboard API (completionPercentage). Here it is seeded with DUMMY values so
-// the module can run locally without the live snapshot cron.
-const vibeProgressSchema = new mongoose.Schema({
- email: { type: String, lowercase: true, trim: true, required: true, index: true },
- course: { type: String, required: true }, // 'onboarding' | 'ai' | 'mern'
- pct: { type: Number, default: 0 }, // completionPercentage 0–100 (from ViBe)
- weekHours: { type: Number, default: 0 }, // content-hours done this week (for the floor)
- priorCompleted: { type: Boolean, default: false } // credited from a prior program (sheet crosswalk)
-}, { timestamps: true });
-
-vibeProgressSchema.index({ email: 1, course: 1 }, { unique: true });
-
-export default mongoose.model('VibeProgress', vibeProgressSchema);
diff --git a/server/routes/engagement.js b/server/routes/engagement.js
new file mode 100644
index 0000000..99aeaf2
--- /dev/null
+++ b/server/routes/engagement.js
@@ -0,0 +1,46 @@
+import { Router } from 'express';
+import { fetchStudentEngagementData } from '../engagement/fetchData.js';
+import { classifyBand } from '../engagement/classifyBand.js';
+import Student from '../models/Student.js';
+
+const router = Router();
+
+router.get('/engagement/:email', async (req, res) => {
+ try {
+ const email = String(req.params.email || '').trim().toLowerCase();
+ if (!email) return res.status(400).json({ error: 'Email is required' });
+
+ const student = await Student.findOne({ email }).lean();
+ if (!student) return res.status(404).json({ error: 'Student not found' });
+
+ const data = await fetchStudentEngagementData(email);
+ const result = classifyBand(data.current, data.previous);
+
+ res.json({
+ email,
+ name: student.name,
+ totalSp: student.totalSp,
+ band: result.band,
+ reason: result.reason,
+ stats: result.stats,
+ windows: {
+ windowSize: data.current.length,
+ current: data.current.map(s => ({
+ label: s.label,
+ attendancePct: s.attendancePct,
+ spDelta: s.spDelta
+ })),
+ previous: data.previous.length > 0 ? data.previous.map(s => ({
+ label: s.label,
+ attendancePct: s.attendancePct,
+ spDelta: s.spDelta
+ })) : null
+ }
+ });
+ } catch (err) {
+ console.error('Engagement API error:', err);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
diff --git a/server/scripts/buildTrajectories.js b/server/scripts/buildTrajectories.js
deleted file mode 100644
index dc36802..0000000
--- a/server/scripts/buildTrajectories.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// Recompute the cohort + onboarding-group average SP trajectories and store the
-// 'latest' TrajectorySnapshot. Run periodically (wire into the analytics cron in prod).
-// node server/scripts/buildTrajectories.js
-import mongoose from 'mongoose';
-import { MONGO_URI } from '../config.js';
-import { computeAndStoreTrajectories } from '../services/trajectory.js';
-
-async function main() {
- await mongoose.connect(MONGO_URI);
- const r = await computeAndStoreTrajectories();
- console.log('trajectory snapshot rebuilt:', r);
- await mongoose.disconnect();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server/scripts/seedVibeDummy.js b/server/scripts/seedVibeDummy.js
deleted file mode 100644
index 2227e28..0000000
--- a/server/scripts/seedVibeDummy.js
+++ /dev/null
@@ -1,155 +0,0 @@
-// Seed DUMMY 16-July-cohort students so the ViBe Goals tab can be demoed locally.
-// Idempotent: upserts by email (all end in @dummy.test) and resets their ViBe rows.
-// Existing real students (all onboarded < 16 Jul) stay ineligible and untouched.
-//
-// node server/scripts/seedVibeDummy.js
-import mongoose from 'mongoose';
-import { MONGO_URI } from '../config.js';
-import Student from '../models/Student.js';
-import VibeProgress from '../models/VibeProgress.js';
-import Commitment from '../models/Commitment.js';
-import SPTransaction from '../models/SPTransaction.js';
-import AttendanceRecord from '../models/AttendanceRecord.js';
-import PollRecord from '../models/PollRecord.js';
-import JourneyProgress from '../models/JourneyProgress.js';
-import JourneyPlan from '../models/JourneyPlan.js';
-
-const D = (y, m, d) => new Date(Date.UTC(y, m - 1, d));
-
-// Build a small SP ledger that ends exactly at `sp`, so the SP Bank has rows.
-function ledgerFor(email, sp, startDay) {
- const rows = []; let bal = 0;
- const push = (category, delta, reason, label, day) => {
- bal += delta;
- rows.push({ email, category, sessionLabel: label, deltaMode: 'absolute',
- deltaValue: delta, appliedDelta: delta, balanceAfter: bal, reason, dateTime: D(2026, 7, day) });
- };
- // insert in strict chronological order so the running balance stays monotonic
- push('initial', 100, 'Welcome bonus on joining Summership', '', startDay);
- push('attendance', 10, 'Attendance credit — evening session', `${startDay + 1} Jul Evening`, startDay + 1);
- push('poll', 5, 'Poll participation', `${startDay + 1} Jul Evening`, startDay + 1);
- push('attendance', 10, 'Attendance credit — evening session', `${startDay + 2} Jul Evening`, startDay + 2);
- push('attendance', 10, 'Attendance credit — evening session', `${startDay + 3} Jul Evening`, startDay + 3);
- const diff = sp - bal; // final adjustment to land exactly on totalSp
- if (diff !== 0) push('manual', diff, diff > 0 ? 'Instructor award' : 'Attendance shortfall adjustment', '', startDay + 3);
- return rows;
-}
-
-// Build dummy Standup evidence (Zoom attendance + Spandan poll rows) so the Journey
-// "Standups" card shows real numbers. std = { sessions, minutes, pollsAttempted, pollsTotal }.
-// One AttendanceRecord + one PollRecord per session (PollRecord is a per-session
-// aggregate, unique on email+sessionLabel), with the poll questions spread evenly.
-function standupRecordsFor(email, studentId, std, startDay) {
- const att = [], polls = [];
- const spread = (total, n, i) => Math.floor(total / n) + (i < total % n ? 1 : 0);
- for (let i = 0; i < std.sessions; i++) {
- const label = `${startDay + i} Jul Evening`;
- att.push({ email, studentId, sessionLabel: label, attendedMinutes: std.minutes,
- totalSessionMinutes: 90, attendancePercentage: Math.round(std.minutes / 90 * 100), qualified: std.minutes >= 68 });
- const tot = spread(std.pollsTotal, std.sessions, i);
- const done = Math.min(tot, spread(std.pollsAttempted, std.sessions, i));
- polls.push({ email, studentId, sessionLabel: label, totalQuestions: tot,
- attemptedQuestions: done, missedQuestions: tot - done, responses: [] });
- }
- return { att, polls };
-}
-
-// name, email, start, sp, prog (per ViBe course), std (standups), spa/proj (placeholder), plan
-const DUMMY = [
- { name: 'Aadhya Rao (dummy)', email: 'aadhya.vibe@dummy.test', start: D(2026,7,16), sp: 300,
- prog: { onboarding:{pct:100}, ai:{pct:40, weekHours:1.5}, mern:{pct:0} },
- std: { sessions:5, minutes:82, pollsAttempted:8, pollsTotal:10 },
- spa: { spaSolved:18, spaPoints:220 }, proj: { prsRaised:2, prsMerged:1 },
- plan: { vibeBy: D(2026,8,20), spaBy: D(2026,9,5), projectBy: D(2026,8,28) } },
- { name: 'Vihaan Menon (dummy)', email: 'vihaan.vibe@dummy.test', start: D(2026,7,17), sp: 150,
- prog: { onboarding:{pct:100}, ai:{pct:10, weekHours:0.5}, mern:{pct:0} },
- std: { sessions:3, minutes:64, pollsAttempted:3, pollsTotal:8 },
- spa: { spaSolved:6, spaPoints:70 }, proj: { prsRaised:0, prsMerged:0 },
- plan: { vibeBy: D(2026,9,1), spaBy: null, projectBy: null } },
- { name: 'Diya Nair (dummy)', email: 'diya.vibe@dummy.test', start: D(2026,7,18), sp: 120,
- prog: { onboarding:{pct:60, weekHours:1.2}, ai:{pct:0}, mern:{pct:0} },
- std: { sessions:4, minutes:78, pollsAttempted:5, pollsTotal:6 },
- spa: { spaSolved:2, spaPoints:20 }, proj: { prsRaised:0, prsMerged:0 },
- plan: { vibeBy: null, spaBy: null, projectBy: null } },
- { name: 'Arjun Iyer (dummy)', email: 'arjun.vibe@dummy.test', start: D(2026,7,20), sp: 500,
- prog: { onboarding:{pct:100}, ai:{pct:100}, mern:{pct:20, weekHours:2} },
- std: { sessions:6, minutes:88, pollsAttempted:11, pollsTotal:12 },
- spa: { spaSolved:41, spaPoints:530 }, proj: { prsRaised:5, prsMerged:4 },
- plan: { vibeBy: D(2026,8,10), spaBy: D(2026,8,25), projectBy: D(2026,8,15) } },
- { name: 'Kabir Shah (dummy)', email: 'kabir.vibe@dummy.test', start: D(2026,7,22), sp: 200,
- prog: { onboarding:{pct:100}, ai:{prior:true}, mern:{pct:5, weekHours:1} },
- std: { sessions:2, minutes:71, pollsAttempted:2, pollsTotal:4 },
- spa: { spaSolved:9, spaPoints:110 }, proj: { prsRaised:1, prsMerged:0 },
- plan: { vibeBy: D(2026,8,31), spaBy: D(2026,9,10), projectBy: null } }
-];
-
-async function main() {
- await mongoose.connect(MONGO_URI);
- const emails = DUMMY.map(d => d.email);
- await Promise.all([
- Commitment.deleteMany({ email: { $in: emails } }),
- VibeProgress.deleteMany({ email: { $in: emails } }),
- SPTransaction.deleteMany({ email: { $in: emails } }),
- AttendanceRecord.deleteMany({ email: { $in: emails } }),
- PollRecord.deleteMany({ email: { $in: emails } }),
- JourneyProgress.deleteMany({ email: { $in: emails } }),
- JourneyPlan.deleteMany({ email: { $in: emails } })
- ]);
-
- for (const d of DUMMY) {
- await Student.updateOne(
- { email: d.email },
- { $set: {
- name: d.name, email: d.email, internshipStartDate: d.start,
- status: 'active', totalSp: d.sp, highestSpEver: d.sp,
- level: 1, trophyLeague: 'Bronze II', leaderboardGroup: '2026-07-16'
- } },
- { upsert: true }
- );
- const stu = await Student.findOne({ email: d.email }).lean();
- for (const [course, p] of Object.entries(d.prog)) {
- await VibeProgress.updateOne(
- { email: d.email, course },
- { $set: { pct: p.pct ?? 0, weekHours: p.weekHours ?? 0, priorCompleted: !!p.prior } },
- { upsert: true }
- );
- }
- await SPTransaction.insertMany(ledgerFor(d.email, d.sp, d.start.getUTCDate()));
-
- // Standups — attendance + poll evidence for the Journey card
- const { att, polls } = standupRecordsFor(d.email, stu._id, d.std, d.start.getUTCDate());
- if (att.length) await AttendanceRecord.insertMany(att);
- if (polls.length) await PollRecord.insertMany(polls);
-
- // SPA + Projects placeholder progress, and the self-declared plan
- await JourneyProgress.updateOne({ email: d.email },
- { $set: { spaSolved: d.spa.spaSolved, spaTotal: 53, spaPoints: d.spa.spaPoints,
- prsRaised: d.proj.prsRaised, prsMerged: d.proj.prsMerged } }, { upsert: true });
- await JourneyPlan.updateOne({ email: d.email },
- { $set: { vibeBy: d.plan.vibeBy, spaBy: d.plan.spaBy, projectBy: d.plan.projectBy } }, { upsert: true });
- }
-
- // A little settled history so the "Past" tables aren't empty.
- await Commitment.create({
- email: 'aadhya.vibe@dummy.test', type: 'vibe', debited: true,
- course: 'ai', goalPct: 20, baselinePct: 20,
- deadline: D(2026,7,18), stake: 100, multiplier: 2,
- potentialWin: 200, potentialLoss: 100, reserved: 0,
- label: '+20% Fundamentals of AI (stake 100 @ 2×)',
- status: 'won', resultDelta: 200, settledAt: D(2026,7,18)
- });
- // A settled standup commitment for Arjun (keep-the-stake: HIT credited the +150 bonus).
- await Commitment.create({
- email: 'arjun.vibe@dummy.test', type: 'standup', debited: false, reserved: 0,
- stake: 50, multiplier: 3, potentialWin: 150, potentialLoss: 75,
- tier: '91-100', tierFloor: 91, sessionsTarget: 6,
- label: 'Attend all 6 standups @ 91–100% (3×)',
- deadline: D(2026,7,19), status: 'won', resultDelta: 150, settledAt: D(2026,7,19)
- });
-
- console.log(`Seeded ${DUMMY.length} dummy 16-July students:`);
- DUMMY.forEach(d => console.log(` ${d.email} (start ${d.start.toISOString().slice(0,10)}, ${d.sp} SP)`));
- await mongoose.disconnect();
-}
-
-main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server/server.js b/server/server.js
index 53579d2..517aa47 100644
--- a/server/server.js
+++ b/server/server.js
@@ -13,6 +13,7 @@ import PollRecord from './models/PollRecord.js';
import SPTransaction from './models/SPTransaction.js';
import SessionEvent from './models/SessionEvent.js';
import { leagueBand, levelFor, legendBadge, leaderboardGroup, groupLabel } from './services/levels.js';
+import engagementRouter from './routes/engagement.js';
import Commitment from './models/Commitment.js';
import { isVibeEligible, buildVibeState, validateBet, settleBetDemo, applySpDelta, courseByKey } from './services/vibe.js';
import { buildStandupState, placeStandup, settleStandupDemo } from './services/standup.js';
@@ -23,14 +24,8 @@ import { buildTrajectoryState } from './services/trajectory.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const clientDist = path.join(rootDir, 'client', 'dist');
-// Admin auth is env-only — NO hardcoded fallback. A committed default would be a
-// public credential (anyone reading the repo could authenticate). If either is
-// unset, admin endpoints fail closed (see isAdmin) rather than accept a known value.
-const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || '');
-const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
-if (!ADMIN_EMAIL || !ADMIN_TOKEN) {
- console.warn('[security] ADMIN_EMAIL/ADMIN_TOKEN not set — admin endpoints are DISABLED until both are configured in .env');
-}
+const ADMIN_EMAIL = normalizeEmail(process.env.ADMIN_EMAIL || 'dled@iitrpr.ac.in');
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'vled-local-admin';
// Survey triangulation pop-up(s). All driven by env so the form link / mode can
// change without a client rebuild (the client reads these via /api/config).
@@ -61,8 +56,7 @@ function makeSurvey(prefix, completedField) {
}
const SURVEY = makeSurvey('SURVEY', 'surveyCompleted');
const POLL2 = makeSurvey('POLL2', 'poll2Completed');
-const POLL3 = makeSurvey('POLL3', 'poll3Completed');
-const SURVEYS = [SURVEY, POLL2, POLL3];
+const SURVEYS = [SURVEY, POLL2];
// Cached fetch of the submitted-email set from a survey's Apps Script endpoint.
async function getSubmittedEmails(cfg) {
@@ -72,17 +66,10 @@ async function getSubmittedEmails(cfg) {
const u = cfg.responsesUrl + (cfg.responsesUrl.includes('?') ? '&' : '?') +
'secret=' + encodeURIComponent(cfg.responsesSecret);
const r = await fetch(u, { redirect: 'follow' });
- // Apps Script intermittently serves an HTML error/redirect page (esp. under
- // load) instead of JSON; parse defensively so it fails cleanly instead of
- // throwing an opaque "Unexpected token '<'".
- const body = await r.text();
- let j;
- try { j = JSON.parse(body); }
- catch { throw new Error(`non-JSON response (HTTP ${r.status}, ${body.length}B)`); }
+ const j = await r.json();
cfg._subs = { at: Date.now(), set: new Set((j.emails || []).map(e => normalizeEmail(e))) };
return cfg._subs.set;
} catch (err) {
- cfg._subs.at = Date.now(); // back off 60s on failure too — don't hammer Apps Script / spam logs
console.error(`${cfg.key} responses fetch failed:`, err?.message);
return cfg._subs.set; // serve last good cache on failure
}
@@ -245,9 +232,7 @@ async function studentPayload(student) {
leaderboardGroup: myGroup,
leaderboardGroupLabel: groupLabel(myGroup),
surveyCompleted: Boolean(student.surveyCompleted),
- poll2Completed: Boolean(student.poll2Completed),
- poll3Completed: Boolean(student.poll3Completed),
- eligibleForVibeGoals: isVibeEligible(student)
+ poll2Completed: Boolean(student.poll2Completed)
},
transactions,
polls,
@@ -265,7 +250,6 @@ async function studentPayload(student) {
}
function isAdmin(req) {
- if (!ADMIN_EMAIL || !ADMIN_TOKEN) return false; // fail closed when admin creds aren't configured
const emailOk = normalizeEmail(req.headers['x-admin-email']) === ADMIN_EMAIL;
const tokenOk = String(req.headers['x-admin-token'] || '') === ADMIN_TOKEN;
return emailOk && tokenOk;
@@ -281,8 +265,7 @@ api.get('/health', (_req, res) => res.json({ status: 'ok' }));
api.get('/config', (_req, res) => res.json({
allowStudentSearch: ALLOW_STUDENT_SEARCH,
survey: surveyPublic(SURVEY),
- poll2: surveyPublic(POLL2),
- poll3: surveyPublic(POLL3)
+ poll2: surveyPublic(POLL2)
}));
api.get('/me', async (req, res) => {
@@ -294,97 +277,6 @@ api.get('/me', async (req, res) => {
res.json({ authenticated: true, profile: await studentPayload(student) });
});
-// ---- ViBe Goals (commitment-SP module; 16 July cohort onward) ----------------
-async function vibeStudent(req) {
- const email = normalizeEmail(req.body?.email || req.query.email) || await studentEmailFromRequest(req);
- if (!email) return null;
- return Student.findOne({ $or: [{ email }, { alternateEmail: email }] }).lean();
-}
-
-api.get('/vibe/state', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' });
- if (!isVibeEligible(student)) return res.json({ eligible: false });
- res.json(await buildVibeState(student));
-});
-
-api.post('/vibe/bet', async (_req, res) => {
- // ON HOLD: ViBe commitments are paused — the ViBe completion feed (leaderboard API)
- // is unavailable, so bets can't be verified or settled. No new bets can be placed
- // (nothing is staked/debited) until the feed is restored.
- return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' });
-});
-
-api.put('/vibe/bet/:id', async (_req, res) => {
- // ON HOLD: see POST /vibe/bet.
- return res.status(403).json({ error: 'ViBe commitments are on hold and will be back up soon.' });
-});
-
-// DEMO: resolve a bet (no live settlement cron locally). result = 'won' | 'lost'.
-api.post('/vibe/bet/:id/settle', async (_req, res) => {
- // LOCKED DOWN (security): client-controlled self-settlement is removed. This route
- // trusted req.body.result (defaulting to "won") and granted SP with NO check against
- // real ViBe course completion — students could place a bet and instantly self-declare
- // a win to mint SP. There is no real completion feed (VibeProgress.pct was written by
- // settleBetDemo itself), so settlement cannot be verified yet; disabled until a
- // server-side/automatic settlement against real completion data is built.
- return res.status(403).json({ error: 'Bets are settled automatically, not on request. Self-settlement is disabled.' });
-});
-
-// ---- SPA → SP (peer-teaching endorsement points; ALL cohorts) ----------------
-// DISPLAY ONLY: SP is scored + credited by the pipeline rubric; this just reads
-// the `spaprogresses` summary + student total. Universal, no cohort gate.
-api.get('/spa/state', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' });
- res.json(await buildSpaState(student));
-});
-
-// ---- SP trajectory (You vs cohort vs onboarding-group; open to all students) --
-// The student's own weekly line is built live from their ledger; the cohort/group
-// reference lines come from the cached TrajectorySnapshot (buildTrajectories.js).
-api.get('/trajectory/state', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' });
- res.json(await buildTrajectoryState(student));
-});
-
-// ---- Standup commitments (weekly, attendance-only; keep-the-stake) -----------
-api.get('/standup/state', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' });
- if (!isVibeEligible(student)) return res.json({ eligible: false });
- res.json(await buildStandupState(student));
-});
-
-api.post('/standup/commit', async (_req, res) => {
- // PAUSED: standups moved to YouTube Live and the attendance module is being
- // reworked — no new standup commitments until the new attendance tracking lands.
- return res.status(403).json({ error: 'Standup commitments are paused while attendance is reworked for YouTube Live.' });
-});
-
-// DEMO: resolve a standup commitment (no live weekly settlement cron yet).
-api.post('/standup/commit/:id/settle', async (_req, res) => {
- // LOCKED DOWN (security): same self-settlement exploit as /vibe/bet/:id/settle —
- // client-declared "won" minted SP with no verification. Disabled until server-side
- // settlement against real attendance/completion is built.
- return res.status(403).json({ error: 'Commitments are settled automatically, not on request. Self-settlement is disabled.' });
-});
-
-// ---- My Journey (phase-by-phase progress + SP; 16 July cohort onward) ---------
-api.get('/journey/state', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' });
- res.json(await buildJourneyState(student)); // My Journey is universal (Phase 1); Commitments stays gated
-});
-
-api.put('/journey/plan', async (req, res) => {
- const student = await vibeStudent(req);
- if (!student) return res.status(404).json({ error: 'Student not found' }); // My Journey goals are universal (Phase 1)
- await saveJourneyPlan(student.email, req.body || {});
- res.json(await buildJourneyState(student));
-});
-
api.get('/search', async (req, res) => {
if (!ALLOW_STUDENT_SEARCH) return res.status(403).json({ error: 'Student search is disabled. Please login from Samagama to view your Spurti Points.' });
const q = String(req.query.q || '').trim();
@@ -512,7 +404,6 @@ function registerSurveyRoutes(base, cfg) {
}
registerSurveyRoutes('/survey', SURVEY);
registerSurveyRoutes('/poll2', POLL2);
-registerSurveyRoutes('/poll3', POLL3);
api.get('/admin/stats', adminGuard, async (_req, res) => {
const [yetToOnboard, excusedStudents, sessions, txns, activeStudents] = await Promise.all([
@@ -729,12 +620,39 @@ api.get('/admin/analytics', adminGuard, async (_req, res) => {
});
});
+api.get('/admin/engagement/report', adminGuard, async (req, res) => {
+ const bandFilter = String(req.query.band || '').trim();
+ const students = await Student.find({ status: 'active' }).lean();
+ const results = [];
+ for (const student of students) {
+ try {
+ const data = await fetchStudentEngagementData(student.email);
+ const result = classifyBand(data.current, data.previous);
+ results.push({ email: student.email, name: student.name, totalSp: student.totalSp, band: result.band, reason: result.reason, stats: result.stats });
+ } catch { /* skip students with no data */ }
+ }
+ const groups = {};
+ for (const r of results) {
+ if (!groups[r.band]) groups[r.band] = [];
+ groups[r.band].push(r);
+ }
+ if (bandFilter) {
+ const filtered = groups[bandFilter] || [];
+ return res.json({ band: bandFilter, count: filtered.length, students: filtered });
+ }
+ const summary = {};
+ for (const [band, list] of Object.entries(groups)) summary[band] = { count: list.length };
+ res.json({ summary, total: results.length, groups });
+});
+
function last24Hours(now) {
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
app.use('/api', api);
app.use('/spurti/api', api);
+app.use('/api', engagementRouter);
+app.use('/spurti/api', engagementRouter);
if (fs.existsSync(clientDist)) {
app.use('/spurti', express.static(clientDist));
diff --git a/server/services/journey.js b/server/services/journey.js
deleted file mode 100644
index 16e5019..0000000
--- a/server/services/journey.js
+++ /dev/null
@@ -1,182 +0,0 @@
-// "My Journey" — the unified phase-by-phase progress + SP view (16 July cohort).
-// Four phases: (1) Standups = Zoom attendance + Spandan polls, (2) ViBe = 3 courses,
-// (3) SPA = Matrix Mystics 53 problems, (4) Projects = PRs.
-//
-// SP attribution per phase:
-// - Standups: ALREADY awarded (attendance + poll SPTransactions) — we just aggregate.
-// - ViBe: net SP from settled commitments (+ the weekly floor) — from the ViBe module.
-// - SPA / Projects: rule TBD until Samagama data lands — shown as "coming soon" (sp = 0).
-import AttendanceRecord from '../models/AttendanceRecord.js';
-import PollRecord from '../models/PollRecord.js';
-import SPTransaction from '../models/SPTransaction.js';
-import Commitment from '../models/Commitment.js';
-import JourneyPlan from '../models/JourneyPlan.js';
-import JourneyProgress from '../models/JourneyProgress.js';
-import { buildVibeState, isVibeEligible } from './vibe.js';
-
-export const SPA_TOTAL = 53;
-export const STANDUP_MINUTES_TARGET = 3600; // cumulative Zoom minutes (~120 min/week)
-
-export async function buildJourneyState(student) {
- const email = student.email;
-
- // --- Phase 1: Standups (attendance + Spandan polls) — existing SP, aggregated ---
- const [att, polls, txns] = await Promise.all([
- AttendanceRecord.find({ email }).lean(),
- PollRecord.find({ email }).lean(),
- SPTransaction.find({ email }).lean()
- ]);
- const spByCat = cats => txns
- .filter(t => cats.includes(t.category))
- .reduce((a, t) => a + (t.appliedDelta || 0), 0);
- const standups = {
- zoomMinutes: att.reduce((a, r) => a + (r.attendedMinutes || 0), 0),
- sessionsAttended: att.filter(r => (r.attendedMinutes || 0) > 0).length,
- pollSessions: polls.length,
- pollsAttempted: polls.reduce((a, p) => a + (p.attemptedQuestions || 0), 0),
- pollsTotal: polls.reduce((a, p) => a + (p.totalQuestions || 0), 0),
- spAttendance: spByCat(['attendance']),
- spPolls: spByCat(['poll'])
- };
- standups.sp = standups.spAttendance + standups.spPolls;
-
- // --- Phase 2: ViBe (3 courses) — summarise the commitment module ---
- const v = await buildVibeState(student);
- const settled = await Commitment.find({ email, type: 'vibe', status: { $in: ['won', 'lost'] } }).lean();
- // Pre-16-July students never had the ViBe Onboarding course — show them AI + MERN only.
- const ladder = isVibeEligible(student) ? v.ladder : v.ladder.filter(l => l.key !== 'onboarding');
- const vibe = {
- ladder,
- current: v.current,
- clearedCount: ladder.filter(l => l.cleared).length,
- totalCourses: ladder.length,
- activeCommitment: v.active
- ? { course: v.active.course, goalPct: v.active.goalPct, deadline: v.active.deadline }
- : null,
- settledCount: settled.length,
- sp: settled.reduce((a, b) => a + (b.resultDelta || 0), 0) // net SP from settled commitments
- };
-
- // --- Phase 3 & 4: SPA + Projects — PLACEHOLDER (Samagama data + SP rule TBD) ---
- const jp = (await JourneyProgress.findOne({ email }).lean()) || {};
- const spa = {
- solved: jp.spaSolved || 0,
- total: jp.spaTotal || SPA_TOTAL,
- spaPoints: jp.spaPoints || 0,
- sp: 0, pending: true // SP rule decided once Samagama data arrives
- };
- const projects = {
- prsRaised: jp.prsRaised || 0,
- prsMerged: jp.prsMerged || 0,
- sp: 0, pending: true // SP rule decided once Samagama data arrives
- };
-
- const plan = await JourneyPlan.findOne({ email }).lean();
-
- // Per-phase goal status + pace toward the student's target date. ViBe has live
- // completion %; SPA/Projects are pending (date countdown only until Samagama data).
- const vibeOverall = Math.round(vibe.ladder.reduce((a, l) => a + l.pct, 0) / (vibe.ladder.length || 1));
- const goals = {
- standup: phaseGoal({ targetDate: plan?.standupBy, current: standups.zoomMinutes, target: STANDUP_MINUTES_TARGET, unit: 'min', daysPerWeek: STANDUP_DAYS_PER_WEEK }),
- vibe: phaseGoal({ targetDate: plan?.vibeBy, current: vibeOverall, target: 100, unit: '%' }),
- spa: phaseGoal({ targetDate: plan?.spaBy, pending: true }),
- project: phaseGoal({ targetDate: plan?.projectBy, pending: true })
- };
- // Attach realistic date bounds so a target can't be set too soon (superficial) or
- // too far out (delayed). Standups is pace-capped at ~60 min per working day.
- const endDate = student.internshipEndDate || null;
- for (const [key, g] of Object.entries(goals)) Object.assign(g, goalBounds(key, g, endDate));
-
- return {
- eligible: true,
- name: student.name,
- totalSp: student.totalSp || 0,
- plan: {
- standupBy: plan?.standupBy || null,
- vibeBy: plan?.vibeBy || null,
- spaBy: plan?.spaBy || null,
- projectBy: plan?.projectBy || null
- },
- goals,
- phaseSp: { standups: standups.sp, vibe: vibe.sp, spa: spa.sp, projects: projects.sp },
- standups, vibe, spa, projects
- };
-}
-
-const DAY_MS = 86400000;
-const startOfToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; };
-const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
-// Local date-only string (yyyy-mm-dd) — matches how works and
-// avoids UTC/local off-by-one between the picker min/max and server validation.
-const ymd = d => { const x = new Date(d); return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`; };
-export const STANDUP_MIN_PER_DAY = 60; // one 60-min standup per working day
-export const STANDUP_DAYS_PER_WEEK = 6; // 6 working days per week
-
-// Realistic [minDate, maxDate] for a goal. Standups are pace-capped (can't beat
-// 60 min/working-day over 6 working days/week); other phases just need a small buffer
-// so it isn't "tomorrow". Upper bound = internship end if known, else 60 days past min.
-function goalBounds(key, goal, endDate) {
- const today = startOfToday();
- let minDate;
- if (key === 'standup' && goal.remaining > 0) {
- const workDays = Math.ceil(goal.remaining / STANDUP_MIN_PER_DAY);
- minDate = addDays(today, Math.ceil((workDays * 7) / STANDUP_DAYS_PER_WEEK)); // working → calendar days
- } else {
- minDate = addDays(today, key === 'project' ? 3 : 7);
- }
- const end = endDate ? new Date(endDate) : null;
- const maxDate = end && end.getTime() > minDate.getTime() ? end : addDays(minDate, 60);
- return { minDate: ymd(minDate), maxDate: ymd(maxDate), paceHint: key === 'standup' ? '≈60 min per working day' : null };
-}
-
-// Goal status + pace for one phase. `current`/`target` are in `unit` (e.g. min, %).
-// none | active | achieved | missed. pending = progress data not available yet.
-function phaseGoal({ targetDate, current = null, target = null, unit = '%', pending = false, daysPerWeek = null }) {
- const t = targetDate ? new Date(targetDate) : null;
- const today = startOfToday();
- const known = !pending && current != null && target != null;
- const complete = known && current >= target;
- let status = 'none';
- if (t) {
- if (complete) status = 'achieved';
- else if (t.getTime() < today.getTime()) status = 'missed';
- else status = 'active';
- }
- const daysLeft = t ? Math.max(0, Math.round((t.getTime() - today.getTime()) / DAY_MS)) : null;
- const progressPct = known ? Math.min(100, Math.round((current / target) * 100)) : null;
- const remaining = known ? Math.max(0, target - current) : null;
- // Pace to stay on track. For a phase with working days (standups), pace is per
- // WORKING day over the working days left; otherwise per calendar day.
- let perDay = null, perDayUnit = 'day';
- if (status === 'active' && remaining != null && daysLeft > 0) {
- if (daysPerWeek) {
- const workDaysLeft = Math.max(1, Math.round((daysLeft * daysPerWeek) / 7));
- perDay = Math.ceil(remaining / workDaysLeft);
- perDayUnit = 'working day';
- } else {
- perDay = Math.ceil(remaining / daysLeft);
- }
- }
- return {
- targetDate: t, hasTarget: !!t, status, unit,
- current: known ? current : null, target: known ? target : null,
- progressPct, remaining, remainingPct: progressPct == null ? null : Math.max(0, 100 - progressPct),
- daysLeft, perDay, perDayUnit, pending: !!pending
- };
-}
-
-// Upsert the plan dates — but LOCK a phase once its target is set and still in the
-// future (a commitment device: no moving the goalpost). A phase only becomes settable
-// again when its date has passed (missed). Only fields present in `incoming` are touched.
-export async function saveJourneyPlan(email, incoming = {}) {
- const existing = await JourneyPlan.findOne({ email }).lean();
- const today = startOfToday();
- const set = {};
- for (const f of ['standupBy', 'vibeBy', 'spaBy', 'projectBy']) {
- const cur = existing?.[f] ? new Date(existing[f]) : null;
- const locked = cur && cur.getTime() >= today.getTime(); // active future target → locked
- if (locked) continue;
- if (Object.prototype.hasOwnProperty.call(incoming, f)) set[f] = incoming[f] ? new Date(incoming[f]) : null;
- }
- if (Object.keys(set).length) await JourneyPlan.updateOne({ email }, { $set: set }, { upsert: true });
-}
diff --git a/server/services/spa.js b/server/services/spa.js
deleted file mode 100644
index be4cafa..0000000
--- a/server/services/spa.js
+++ /dev/null
@@ -1,61 +0,0 @@
-// SPA → SP module logic (peer-teaching endorsement activity) — DISPLAY layer.
-// The SP itself is scored + credited by the pipeline rubric
-// (pipeline/sp-rubric-build-mirror.cjs, Pattern A): +5 per validated question
-// learned, +8 per validated peer taught (capped 50/30), minus a one-time
-// audit/fraud penalty. The rubric writes the `spaprogresses` summary this reads.
-// The web app NEVER writes SP — it only renders what the rubric produced.
-// Universal: applies to ALL cohorts (15-May onward).
-import Student from '../models/Student.js';
-import SpaProgress from '../models/SpaProgress.js';
-
-export function isSpaEligible() { return true; } // SPA SP is a universal feature.
-
-// LOCKED scheme (see memory: spa-sp-award-scheme).
-export const CONFIG = {
- learnUnit: 5, learnCap: 50, // +5 SP per validated question learned, cap 50 → max 250
- teachUnit: 8, teachCap: 30, // +8 SP per validated endorsement given, cap 30 → max 240
- fraudRate: 0.5, // genuine fraud → -50% of current SP (one-time)
- auditRate: 0.2 // audit failure → -20% of current SP (one-time)
-};
-export const MAX_SPA_SP = CONFIG.learnUnit * CONFIG.learnCap + CONFIG.teachUnit * CONFIG.teachCap; // 490
-
-// Pure computation of the SP breakdown from a SpaProgress row (display).
-export function computeSpaSp(prog) {
- const learnCounted = Math.min(prog.learnValidated || 0, CONFIG.learnCap);
- const teachCounted = Math.min(prog.teachValidated || 0, CONFIG.teachCap);
- return {
- learn: { validated: prog.learnValidated || 0, counted: learnCounted, credited: prog.learnCredited || 0,
- cap: CONFIG.learnCap, unit: CONFIG.learnUnit, sp: learnCounted * CONFIG.learnUnit },
- teach: { validated: prog.teachValidated || 0, counted: teachCounted, credited: prog.teachCredited || 0,
- cap: CONFIG.teachCap, unit: CONFIG.teachUnit, sp: teachCounted * CONFIG.teachUnit },
- grossSp: learnCounted * CONFIG.learnUnit + teachCounted * CONFIG.teachUnit,
- penalty: { fraud: !!prog.fraud, auditFail: !!prog.auditFail,
- rate: prog.fraud ? CONFIG.fraudRate : (prog.auditFail ? CONFIG.auditRate : 0),
- applied: prog.penaltyApplied || 0, at: prog.penaltyAt || null,
- done: (prog.penaltyApplied || 0) > 0 }
- };
-}
-
-// Build the student-facing SPA state. DISPLAY ONLY — the SP itself is scored and
-// credited by the pipeline rubric (sp-rubric-build-mirror.cjs, Pattern A), which
-// also writes the `spaprogresses` summary this reads. The web app never writes SP.
-export async function buildSpaState(student) {
- const prog = await SpaProgress.findOne({ email: student.email }).lean();
- const stu = await Student.findOne({ email: student.email }).lean();
- if (!prog) {
- return { eligible: true, name: student.name, totalSp: stu?.totalSp || 0,
- activity: 'Activity 1: Linear Algebra', hasActivity: false, config: CONFIG, maxSp: MAX_SPA_SP };
- }
- const calc = computeSpaSp(prog);
- return {
- eligible: true,
- name: student.name,
- totalSp: stu?.totalSp || 0,
- activity: prog.activity,
- hasActivity: (prog.learnValidated || 0) + (prog.teachValidated || 0) > 0,
- ...calc,
- creditedSp: calc.learn.credited * CONFIG.learnUnit + calc.teach.credited * CONFIG.teachUnit,
- maxSp: MAX_SPA_SP,
- config: CONFIG
- };
-}
diff --git a/server/services/standup.js b/server/services/standup.js
deleted file mode 100644
index 4975e5b..0000000
--- a/server/services/standup.js
+++ /dev/null
@@ -1,114 +0,0 @@
-// Standup commitment — a WEEKLY, attendance-only pledge (polls stay as poll-points).
-// The student pledges to attend ALL of this week's standups at a chosen attendance
-// tier, with a confidence multiplier. "Keep-the-stake" economics: the stake is NOT
-// debited (it represents the attendance points you earn that week); a HIT pays a
-// +stake×mult bonus on top of your earned attendance, a MISS charges −0.5×stake×mult.
-//
-// Anti-mining: the tier floor is ≥81% and the pledge is the FULL week — you can't
-// farm SP by pledging a low bar or a single session.
-import Student from '../models/Student.js';
-import AttendanceRecord from '../models/AttendanceRecord.js';
-import Commitment from '../models/Commitment.js';
-import { applySpDelta } from './vibe.js';
-
-export const STANDUP = {
- sessionsPerWeek: 6, // Y — standups scheduled per week (6/6)
- multipliers: [2, 3, 4],
- penaltyFactor: 0.5,
- // Two attendance tiers. Higher tier = higher bar (≥91%) and a larger stake cap,
- // to nudge students toward consistent 91–100% attendance.
- tiers: [
- { key: '81-90', label: '81–90%', floor: 81, stake: 20 },
- { key: '91-100', label: '91–100%', floor: 91, stake: 50 }
- ]
-};
-
-export const tierByKey = k => STANDUP.tiers.find(t => t.key === k);
-
-// Current calendar week, Monday 00:00 → Sunday 23:59:59 (local server time).
-function weekWindow(now = new Date()) {
- const start = new Date(now); start.setHours(0, 0, 0, 0);
- const dow = (start.getDay() + 6) % 7; // 0 = Monday
- start.setDate(start.getDate() - dow);
- const end = new Date(start); end.setDate(end.getDate() + 6); end.setHours(23, 59, 59, 999);
- return { start, end };
-}
-const fmt = d => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
-
-export async function buildStandupState(student) {
- const email = student.email;
- const { start, end } = weekWindow();
-
- // Attendance already logged this week (informational — the demo settles by button).
- const wk = await AttendanceRecord.find({ email }).lean();
- const thisWeek = wk.filter(r => r.createdAt && new Date(r.createdAt) >= start && new Date(r.createdAt) <= end);
- const attendedThisWeek = thisWeek.filter(r => (r.attendedMinutes || 0) > 0).length;
- const avgPctThisWeek = attendedThisWeek
- ? Math.round(thisWeek.reduce((a, r) => a + (r.attendancePercentage || 0), 0) / attendedThisWeek)
- : null;
-
- const commits = await Commitment.find({ email, type: 'standup' }).sort({ createdAt: -1 }).lean();
- const active = commits.find(c => c.status === 'active') || null;
- const history = commits.filter(c => c.status !== 'active');
- const reserved = active ? active.reserved : 0;
- const available = Math.max(0, (student.totalSp || 0) - reserved);
-
- return {
- eligible: true,
- name: student.name,
- weekLabel: `${fmt(start)} – ${fmt(end)}`,
- deadline: end,
- sessionsThisWeek: STANDUP.sessionsPerWeek,
- attendedThisWeek, avgPctThisWeek,
- tiers: STANDUP.tiers, multipliers: STANDUP.multipliers, penaltyFactor: STANDUP.penaltyFactor,
- totalSp: student.totalSp || 0, available,
- active, history
- };
-}
-
-// Validate a standup pledge. Stake is FIXED at the tier cap (not chosen). Returns
-// { errs, win, loss, stake, tier, deadline, label }.
-export function validateStandup({ state, tierKey, multiplier }) {
- const errs = [];
- const tier = tierByKey(tierKey);
- if (!tier) errs.push('Pick an attendance tier.');
- if (!STANDUP.multipliers.includes(multiplier)) errs.push('Invalid confidence multiplier.');
- if (state.active) errs.push('You already have an active standup commitment this week.');
-
- const stake = tier ? tier.stake : 0;
- const win = stake * multiplier;
- const loss = STANDUP.penaltyFactor * stake * multiplier;
- // Keep-the-stake: nothing is debited now, but a MISS charges the penalty — so the
- // student must be able to cover the potential loss (SP never goes negative).
- if (loss > state.available) {
- errs.push(`You need ${loss} SP free to cover a possible miss (−${loss}); you have ${state.available}.`);
- }
- const label = tier ? `Attend all ${state.sessionsThisWeek} standups @ ${tier.label} (${multiplier}×)` : '';
- return { errs, win, loss, stake, tier, deadline: state.deadline, label };
-}
-
-export async function placeStandup(student, { tierKey, multiplier }) {
- const state = await buildStandupState(student);
- const v = validateStandup({ state, tierKey, multiplier: +multiplier });
- if (v.errs.length) return { error: v.errs.join(' ') };
- const { start, end } = weekWindow();
- await Commitment.create({
- email: student.email, type: 'standup', debited: false, reserved: 0,
- stake: v.stake, multiplier: +multiplier, potentialWin: v.win, potentialLoss: v.loss,
- tier: v.tier.key, tierFloor: v.tier.floor, sessionsTarget: state.sessionsThisWeek,
- weekStart: start, weekEnd: end, deadline: end, label: v.label, status: 'active'
- });
- return { ok: true };
-}
-
-// DEMO: resolve a standup commitment (no live weekly settlement cron yet). Keep-the-
-// stake: a HIT credits +potentialWin, a MISS debits −potentialLoss. No prior debit to
-// reconcile. In production this is judged automatically at week's end:
-// HIT ⇔ sessions attended ≥ target AND average attendance % ≥ tier floor.
-export async function settleStandupDemo(commitment, result) {
- const delta = result === 'won' ? commitment.potentialWin : -commitment.potentialLoss;
- await applySpDelta(commitment.email, delta,
- `Standup goal ${result === 'won' ? 'HIT' : 'MISS'}: ${commitment.label}`);
- await Commitment.updateOne({ _id: commitment._id },
- { $set: { status: result, resultDelta: delta, settledAt: new Date() } });
-}
diff --git a/server/services/trajectory.js b/server/services/trajectory.js
deleted file mode 100644
index f75cebb..0000000
--- a/server/services/trajectory.js
+++ /dev/null
@@ -1,100 +0,0 @@
-// Student SP trajectories — cumulative SP by WEEK SINCE JOIN (normalised onset, so
-// students who joined on different dates are comparable; matches the trajectory-paper
-// framing). Three lines on the chart: You / Cohort mean / your Onboarding-group mean.
-// Cohort & group means are precomputed (see buildTrajectories.js); the student's own
-// line is built live from their ledger.
-import Student from '../models/Student.js';
-import SPTransaction from '../models/SPTransaction.js';
-import TrajectorySnapshot from '../models/TrajectorySnapshot.js';
-import { leaderboardGroup, groupLabel } from './levels.js';
-
-export const WEEKS = 10; // cap the axis at ~10 weeks (typical internship length)
-const WEEK_MS = 7 * 86400000;
-
-// Balance at time t = balanceAfter of the last txn on/before t (0 before any txn).
-// txns must be sorted ascending by dateTime.
-function balanceAt(txns, t) {
- let bal = 0;
- for (const tx of txns) {
- if (new Date(tx.dateTime).getTime() <= t) bal = tx.balanceAfter; else break;
- }
- return bal;
-}
-
-// Cumulative SP at the end of each COMPLETED week since join. Week is 1-indexed.
-function weeklySeries(txns, joinMs, nowMs) {
- const out = [];
- for (let w = 0; w < WEEKS; w++) {
- const boundary = joinMs + (w + 1) * WEEK_MS;
- if (nowMs < boundary) break; // this week isn't complete yet
- out.push({ week: w + 1, sp: balanceAt(txns, boundary) });
- }
- return out;
-}
-
-// Average per week, dropping weeks with too few students (the noisy small-sample tail —
-// few learners reach the last weeks). Keep weeks with n >= max(15, 10% of week-1's count).
-function toSeries(acc) {
- const first = acc.find(x => x.n > 0);
- const floor = first ? Math.max(15, Math.round(first.n * 0.1)) : 15;
- return acc
- .map((x, i) => ({ week: i + 1, sp: x.n ? Math.round(x.sum / x.n) : null, n: x.n }))
- .filter(p => p.sp !== null && p.n >= floor);
-}
-
-// Recompute cohort + per-group average trajectories and upsert the 'latest' snapshot.
-export async function computeAndStoreTrajectories(now = new Date()) {
- const nowMs = now.getTime();
- const students = await Student.find({ status: { $ne: 'excused' }, internshipStartDate: { $ne: null } })
- .select('email internshipStartDate').lean();
-
- const txns = await SPTransaction.find({}).select('email dateTime balanceAfter').sort({ dateTime: 1 }).lean();
- const byEmail = new Map();
- for (const t of txns) { const a = byEmail.get(t.email); if (a) a.push(t); else byEmail.set(t.email, [t]); }
-
- const blank = () => Array.from({ length: WEEKS }, () => ({ sum: 0, n: 0 }));
- const cohort = blank();
- const groups = new Map();
-
- for (const s of students) {
- const joinMs = new Date(s.internshipStartDate).getTime();
- if (joinMs > nowMs) continue; // not started yet
- const series = weeklySeries(byEmail.get(s.email) || [], joinMs, nowMs);
- if (!series.length) continue;
- const gk = leaderboardGroup(s.internshipStartDate);
- if (!groups.has(gk)) groups.set(gk, blank());
- const garr = groups.get(gk);
- for (const p of series) {
- const i = p.week - 1;
- cohort[i].sum += p.sp; cohort[i].n++;
- garr[i].sum += p.sp; garr[i].n++;
- }
- }
-
- const groupsObj = {}, groupLabels = {};
- for (const [gk, arr] of groups) { groupsObj[gk] = toSeries(arr); groupLabels[gk] = groupLabel(gk); }
-
- await TrajectorySnapshot.updateOne({ key: 'latest' },
- { $set: { weeks: WEEKS, cohort: toSeries(cohort), groups: groupsObj, groupLabels, computedAt: now } },
- { upsert: true });
- return { students: students.length, cohortWeeks: toSeries(cohort).length, groups: groups.size };
-}
-
-// Student-facing payload: their own weekly line + the two cached reference lines.
-export async function buildTrajectoryState(student) {
- const joinMs = student.internshipStartDate ? new Date(student.internshipStartDate).getTime() : null;
- const txns = joinMs
- ? await SPTransaction.find({ email: student.email }).select('dateTime balanceAfter').sort({ dateTime: 1 }).lean()
- : [];
- const you = joinMs ? weeklySeries(txns, joinMs, Date.now()) : [];
- const snap = await TrajectorySnapshot.findOne({ key: 'latest' }).lean();
- const gk = student.internshipStartDate ? leaderboardGroup(student.internshipStartDate) : null;
- return {
- weeks: snap?.weeks || WEEKS,
- you,
- cohort: snap?.cohort || [],
- group: (gk && snap?.groups?.[gk]) || [],
- groupLabel: gk ? groupLabel(gk) : null,
- computedAt: snap?.computedAt || null
- };
-}
diff --git a/server/services/vibe.js b/server/services/vibe.js
deleted file mode 100644
index a4e5572..0000000
--- a/server/services/vibe.js
+++ /dev/null
@@ -1,149 +0,0 @@
-// ViBe Commitment-SP module logic (16 July cohort onward).
-// Config + eligibility + state builder + bet validation + demo settlement.
-// Progress here comes from VibeProgress (dummy locally; ViBe API in production).
-import Student from '../models/Student.js';
-import VibeProgress from '../models/VibeProgress.js';
-import Commitment from '../models/Commitment.js';
-import SPTransaction from '../models/SPTransaction.js';
-
-export const ELIGIBILITY_CUTOFF = new Date('2026-07-16T00:00:00.000Z');
-
-// Progressive ladder order: Onboarding -> AI -> MERN.
-export const COURSES = [
- { key: 'onboarding', name: 'Onboarding', hours: 10, courseId: '6a14258a4fa5339bade5d732', versionId: '6a14258a4fa5339bade5d733' },
- { key: 'ai', name: 'Fundamentals of AI', hours: 6, courseId: '6a055c4c79eef782c2548388', versionId: '6a055c4c79eef782c2548389' },
- { key: 'mern', name: 'MERN Stack', hours: 10, courseId: '6a0ec8254658465536acb121', versionId: '6a0ec8254658465536acb122' }
-];
-
-export const CONFIG = {
- stakeMin: 50, stakeMax: 200,
- multipliers: [2, 3, 4], // 1x dropped: under stake-debit, a 1x hit only returns the stake (net 0)
- penaltyFactor: 0.5, // miss loses 0.5 * stake * multiplier
- maxBetDays: 3, // deadline window 1–3 days
- floorHours: 1, // 1 hour of content/week is mandatory
- floorSp: 10 // flat SP for hitting the weekly floor
-};
-
-export function isVibeEligible(student) {
- return Boolean(student?.internshipStartDate) &&
- new Date(student.internshipStartDate) >= ELIGIBILITY_CUTOFF;
-}
-export const courseByKey = k => COURSES.find(c => c.key === k);
-export const floorPctFor = course => Math.round(CONFIG.floorHours / course.hours * 100);
-
-function daysFromToday(deadline) {
- const t = new Date(); t.setHours(0, 0, 0, 0);
- const d = new Date(deadline); d.setHours(0, 0, 0, 0);
- return Math.round((d - t) / 86400000);
-}
-
-// Build the full student-facing ViBe state.
-export async function buildVibeState(student) {
- const email = student.email;
- const rows = await VibeProgress.find({ email }).lean();
- const prog = {};
- rows.forEach(r => { prog[r.course] = { pct: r.pct, week: r.weekHours, prior: !!r.priorCompleted }; });
-
- const ladder = COURSES.map(c => {
- const p = prog[c.key] || { pct: 0, week: 0, prior: false };
- const pct = p.prior ? 100 : p.pct;
- return { key: c.key, name: c.name, hours: c.hours, pct, prior: p.prior, cleared: p.prior || pct >= 100 };
- });
- const currentLadder = ladder.find(l => !l.cleared) || null;
- const currentCourse = currentLadder ? courseByKey(currentLadder.key) : null;
-
- const bets = await Commitment.find({ email, type: 'vibe' }).sort({ createdAt: -1 }).lean();
- const active = bets.find(b => b.status === 'active') || null;
- const history = bets.filter(b => b.status !== 'active');
- const reserved = active ? active.reserved : 0;
- const available = Math.max(0, (student.totalSp || 0) - reserved);
-
- const current = currentLadder ? {
- key: currentLadder.key,
- name: currentLadder.name,
- pct: currentLadder.pct,
- hours: currentLadder.hours,
- floorPct: floorPctFor(currentCourse),
- remaining: 100 - currentLadder.pct,
- weekHours: prog[currentLadder.key]?.week ?? 0
- } : null;
-
- return {
- eligible: true,
- name: student.name,
- totalSp: student.totalSp || 0,
- available, reserved,
- ladder, current,
- weeklyFloor: current
- ? { requiredHours: CONFIG.floorHours, doneHours: current.weekHours, met: current.weekHours >= CONFIG.floorHours, sp: CONFIG.floorSp }
- : null,
- active, history,
- config: CONFIG
- };
-}
-
-// Validate a place/edit request against the locked rules. Returns { errs, win, loss, baselinePct }.
-export function validateBet({ state, course, goalPct, stake, multiplier, deadline, ignoreActive = false }) {
- const errs = [];
- const c = courseByKey(course);
- if (!c) errs.push('Unknown course.');
- if (!state.current || state.current.key !== course) errs.push('You can only bet on your current course.');
- if (!ignoreActive && state.active) errs.push('You already have an active bet.');
- if (!CONFIG.multipliers.includes(multiplier)) errs.push('Invalid multiplier.');
- if (!(stake >= CONFIG.stakeMin && stake <= CONFIG.stakeMax)) errs.push(`Stake must be ${CONFIG.stakeMin}–${CONFIG.stakeMax} SP.`);
-
- const floorPct = c ? floorPctFor(c) : 0;
- const remaining = state.current ? state.current.remaining : 0;
- if (goalPct <= floorPct) errs.push(`Goal must beat the weekly floor (${floorPct}%).`);
- if (goalPct > remaining) errs.push(`Goal exceeds your remaining ${remaining}%.`);
-
- if (deadline !== undefined) {
- const days = daysFromToday(deadline);
- if (days < 1 || days > CONFIG.maxBetDays) errs.push(`Deadline must be 1–${CONFIG.maxBetDays} days out.`);
- }
-
- const loss = CONFIG.penaltyFactor * stake * multiplier;
- const win = stake * multiplier;
- // The stake is debited up-front; a miss debits the penalty on top. So you must
- // be able to cover BOTH (worst case = stake + loss). When editing, this bet's
- // already-debited stake and reserved penalty are unwound first.
- const avail = state.available + (ignoreActive && state.active ? state.active.reserved + state.active.stake : 0);
- const need = stake + loss;
- if (need > avail) {
- errs.push(`You need ${need} SP to place this (stake ${stake} + up to ${loss} loss); you have ${avail}.`);
- }
- return { errs, win, loss, baselinePct: state.current ? state.current.pct : 0 };
-}
-
-// Apply an SP change AND write a matching SP-Bank transaction so the student sees
-// it. Stamps the entry after the latest one so the running balance stays ordered
-// (dummy seed dates can be future-ish). Returns the new balance.
-export async function applySpDelta(email, delta, reason) {
- const student = await Student.findOne({ email });
- const newTotal = (student.totalSp || 0) + delta;
- student.totalSp = newTotal;
- if (newTotal > (student.highestSpEver || 0)) student.highestSpEver = newTotal;
- await student.save();
- const last = await SPTransaction.findOne({ email }).sort({ dateTime: -1 }).lean();
- const when = new Date(Math.max(Date.now(), (last?.dateTime ? new Date(last.dateTime).getTime() + 60000 : 0)));
- await SPTransaction.create({
- email, studentId: student._id, category: 'manual', sessionLabel: '',
- deltaMode: 'absolute', deltaValue: delta, appliedDelta: delta, balanceAfter: newTotal, reason, dateTime: when
- });
- return newTotal;
-}
-
-// DEMO ONLY: resolve a bet (there is no live settlement cron locally). The stake
-// was already debited at placement; here we apply the win (credit) or the miss
-// penalty (debit), advance progress, and mark the bet.
-export async function settleBetDemo(bet, result) {
- const course = courseByKey(bet.course);
- const label = `+${bet.goalPct}% ${course ? course.name : bet.course} (stake ${bet.stake} @ ${bet.multiplier}×)`;
- const delta = result === 'won' ? bet.potentialWin : -bet.potentialLoss;
- await applySpDelta(bet.email, delta, `ViBe goal ${result === 'won' ? 'HIT' : 'MISS'}: ${label}`);
- const newPct = result === 'won'
- ? Math.min(100, bet.baselinePct + bet.goalPct)
- : Math.min(100, bet.baselinePct + Math.floor(bet.goalPct * 0.6)); // fell short of goal
- await VibeProgress.updateOne({ email: bet.email, course: bet.course }, { $set: { pct: newPct } }, { upsert: true });
- await Commitment.updateOne({ _id: bet._id }, { $set: { status: result, resultDelta: delta, settledAt: new Date() } });
-}