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

Filter by extension

Filter by extension

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

- **Initial:** +100 to every *started intern* on their official start date.
Future-start interns are zeroed; non-intern roster entries are set aside.
- **Attendance (A):** presence clipped to the official window, `pct = clipped /
window`, then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**.
- **Before 2026-07-16 (morning standup):** window `[09:05 IST, min(first-instance-end, 11:00 IST)]`.
- **From 2026-07-16 (standup moved to evening):** window `[20:05 IST, min(picked-mtg-end, 21:00 IST)]`
and the scored meeting is the mandatory meeting with the **largest overlap**
of that evening window (not just the earliest-starting one — the all-day
persistent room must not steal the slot). Cutover + times are constants at
the top of `sp-rubric-build-mirror.cjs`: `EVENING_CUTOVER`,
`EVENING_WSTART_IST`, `EVENING_WEND_IST`. Change these if the timing shifts again.
- **Attendance (A):** presence clipped to the official window
`[09:05 IST, min(first-instance-end, 11:00 IST)]`; `pct = clipped / window`,
then banded: **≥90% → +10, 75–89% → +5, 50–74% → +3, <50% → 0**.
- **Poll (B):** `pct = answered / totalQuestions`, same band ladder (10/5/3/0).
- **Grace day 2026-06-06:** 1-min join = full attendance + full poll.
- **Chat / discretionary:** admin-reviewed via ChatSPReview in the web app
Expand All @@ -142,6 +136,32 @@ scoring — the `pipeline/` rubric is authoritative. The old Zoom ±5 ingest
- `POST /api/admin/chat-sp-reviews/:id/accept` — award SP
- `POST /api/admin/chat-sp-reviews/:id/reject` — reject

## Engagement Classification (Chunks 1–7, 2026-07-15)

Classifies students into 4 engagement bands based on a rolling window of sessions.

### Files
- `server/engagement/config.js` — rolling window size (N=3), band thresholds, 4 band labels
- `server/engagement/fetchData.js` — fetches attendance + SP transactions, splits into current/previous windows
- `server/engagement/classifyBand.js` — pure function: `classifyBand(current, previous)` → `{ band, reason, stats }`
- `server/routes/engagement.js` — Express router for the single-student endpoint

### Bands
| Band | Criteria | Description |
|------|----------|-------------|
| **Excellent** | avg attendance ≥90%, avg SP ≥8/session | High attendance, strong SP gain |
| **Active** | avg attendance ≥75%, avg SP ≥3/session | Consistent attendance, moderate SP |
| **Slowing Down** | avg attendance <75% OR declining trend | Dropping off, risk of falling behind |
| **Recovery** | prior window was Slowing Down, now improving | Trend reversal detected |

### Endpoints
- `GET /api/engagement/:email` — Single student engagement band + window summary
- Response: `{ email, name, totalSp, band, reason, stats, windows: { current, previous } }`
- `GET /api/admin/engagement/report` — All active students grouped by band (admin auth required)
- Optional: `?band=Excellent|Active|Slowing Down|Recovery` to filter
- Response: `{ summary: { Excellent: { count }, ... }, total, groups }`
- Auth headers: `x-admin-email: dled@iitrpr.ac.in`, `x-admin-token: vled-local-admin`

## Auth — `chatengine_token` cookie passthrough (LIVE since 2026-06-29)
Spurti lives at `samagama.in/spurti` (same domain as Samagama), so the browser
already holds the student's **`chatengine_token`** cookie. There is **no login
Expand Down Expand Up @@ -174,19 +194,6 @@ Code: `getSamagamaUser` / `studentEmailFromRequest` in `server/server.js`.
- **To verify new ingestion:** After running `ingestSession`, check that: (a) new session appears in `sessions` collection, (b) transaction count increases, (c) for a sample student, balance in `sptransactions` matches their `totalSp` in `students` table, (d) leaderboard API reflects updated SP

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

Expand Down
1 change: 1 addition & 0 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Summership SP Record</title>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
Expand Down
115 changes: 115 additions & 0 deletions client/src/components/engagement/AdminBandGrid.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React, { useEffect, useState } from 'react';
import BlockIcon from './BlockIcon';

const BAND_ORDER = ['Excellent', 'Active', 'Recovery', 'Slowing Down'];

export default function AdminBandGrid({ auth }) {
const [groups, setGroups] = useState(null);
const [summary, setSummary] = useState(null);
const [total, setTotal] = useState(0);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [hovered, setHovered] = useState(null);

const headers = auth ? {
'X-Admin-Email': auth.email,
'X-Admin-Token': auth.token
} : {};

useEffect(() => {
if (!auth) return;
setLoading(true);
const base = window.location.pathname.startsWith('/spurti') ? '/spurti' : '';
const url = filter ? `${base}/api/admin/engagement/report?band=${encodeURIComponent(filter)}` : `${base}/api/admin/engagement/report`;
fetch(url, { headers })
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return;
if (filter) {
setGroups({ [filter]: d.students || [] });
setSummary({ [filter]: { count: d.count } });
setTotal(d.count);
} else {
setGroups(d.groups || {});
setSummary(d.summary || {});
setTotal(d.total || 0);
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, [auth, filter]);

if (!auth) {
return <section className="panel"><p className="muted">Admin login required to view engagement report.</p></section>;
}

if (loading) {
return (
<section className="panel">
<div className="mc-loading">Loading report...</div>
</section>
);
}

const filteredGroups = filter ? { [filter]: groups?.[filter] || [] } : groups || {};

return (
<section className="panel">
<div className="panel-head">
<h2>Engagement Report</h2>
<span style={{ color: 'var(--muted)', fontSize: 13 }}>{total} students</span>
</div>

<div className="mc-filter-bar">
<button className={`mc-filter-btn ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>All</button>
{BAND_ORDER.map(band => (
<button
key={band}
className={`mc-filter-btn ${filter === band ? 'active' : ''}`}
onClick={() => setFilter(filter === band ? '' : band)}
>
{band}
</button>
))}
</div>

<div className="mc-grid-wrap">
{BAND_ORDER.map(band => {
const students = filteredGroups[band];
if (!students || students.length === 0) return null;
return (
<div key={band} className="mc-band-row">
<h3>
<BlockIcon band={band} size="sm" />
{band}
<span>{students.length} student{students.length !== 1 ? 's' : ''}</span>
</h3>
<div className="mc-grid">
{students.map(s => (
<div
key={s.email}
className="mc-grid-item"
onMouseEnter={() => setHovered(s.email)}
onMouseLeave={() => setHovered(null)}
>
<BlockIcon band={s.band} size="sm" />
{hovered === s.email && (
<div className="mc-tooltip" style={{ bottom: 'calc(100% + 4px)' }}>
<strong>{s.name}</strong><br />
{s.reason}
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>

{Object.keys(filteredGroups).length === 0 && (
<p className="muted" style={{ textAlign: 'center', padding: 20 }}>No students found in this band.</p>
)}
</section>
);
}
38 changes: 38 additions & 0 deletions client/src/components/engagement/BlockIcon.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

const BAND_MAP = {
'Excellent': 'Excellent',
'Active': 'Active',
'Slowing Down': 'Slowing',
'Recovery': 'Recovery'
};

function getFleckPositions(size) {
if (size === 'sm') return [[6,6], [20,18], [14,10]];
if (size === 'md') return [[10,8], [30,26], [20,14], [36,10]];
if (size === 'lg') return [[12,12], [40,36], [26,18], [48,10], [20,44]];
return [[4,4], [14,12], [10,7]];
}

export default function BlockIcon({ band, size = 'md', dimmed = false, showTooltip = false, reason = '' }) {
const cls = BAND_MAP[band] || 'Insufficient';
const sizeCls = `mc-block-${size}`;
const flecks = getFleckPositions(size);

const block = (
<div className={`mc-block ${sizeCls} mc-band-${cls}${dimmed ? ' mc-dimmed' : ' mc-active-block mc-glow-' + cls}`}>
{flecks.map(([x, y], i) => (
<i key={i} className="mc-fleck" style={{ left: x, top: y }} />
))}
</div>
);

if (!showTooltip || !reason) return block;

return (
<div style={{ position: 'relative', display: 'inline-flex' }}>
{block}
<div className="mc-tooltip">{reason}</div>
</div>
);
}
63 changes: 63 additions & 0 deletions client/src/components/engagement/StudentBandCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import BlockIcon from './BlockIcon';

const BAND_ORDER = ['Slowing Down', 'Recovery', 'Active', 'Excellent'];

export default function StudentBandCard({ email }) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);

useEffect(() => {
if (!email) return;
setData(null);
setError(null);
const base = window.location.pathname.startsWith('/spurti') ? '/spurti' : '';
fetch(`${base}/api/engagement/${encodeURIComponent(email)}`)
.then(r => r.ok ? r.json() : null)
.then(d => d ? setData(d) : setError('No engagement data'))
.catch(() => setError('Failed to load'));
}, [email]);

if (error) return null;
if (!data) {
return (
<div className="mc-spec-card">
<div className="mc-loading">Loading band...</div>
</div>
);
}

const { band, reason, stats } = data;
const currentAtt = stats?.avgAttendancePct ?? '—';
const currentSp = stats?.avgSpPerSession ?? '—';

return (
<div className="mc-spec-card">
<div className="mc-spec-header">
<h3>Progress Band</h3>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>
Att: {currentAtt}% &middot; SP/session: {currentSp}
</span>
</div>

<div className="mc-spectrum">
{BAND_ORDER.map((b, i) => {
const isActive = b === band;
return (
<React.Fragment key={b}>
{i > 0 && (
<div className={`mc-spectrum-connector${isActive || BAND_ORDER.indexOf(band) >= i ? ' active' : ''}`} />
)}
<div className={`mc-spectrum-item${isActive ? ' active' : ''}`}>
<BlockIcon band={b} size="sm" dimmed={!isActive} />
<span className="mc-label">{b}</span>
</div>
</React.Fragment>
);
})}
</div>

<p className="mc-spec-reason">{reason}</p>
</div>
);
}
Loading