17 KiB
Phase 2 — Analytics Dashboard Rebuild
Date: 2026-07-22
Owner: Mavis (orchestrator) + backend-expert / frontend-expert / database-expert / tester reins
Branch (target): feature/analytics-dashboard-2026-07-22 (worktree from dev, branched after Phase 1 lands)
Status: plan (not started)
Assignee: Arthur
Companion plans: Phase 1 — auxiliary roles + cohorts + assignments · Phase 3 — offboarding · superseded 7-feature draft
Parallelizable with: Phase 3 (zero file overlap; both can develop in parallel after Phase 1 lands)
Problem
The /reports page is a single screen with one filter, not a real analytics dashboard. The principal (who shares the /dashboard/admin route with school_admin) needs to answer questions like:
- How many active enrollments do we have right now, and how does that compare to last term?
- What's the attendance rate for Form 4 IGCSE 2026 cohort over the last 30 days?
- Which class has the lowest pass rate this term?
- What's the fee collection rate per cohort?
- Where are the bottlenecks in leave / payroll approval?
The current page renders 4–5 Recharts widgets from /api/reports/summary but is effectively a static report — no time-range filter that's actually wired, no drilldowns, no role-scoped data, no export, and principal is not in the allowed-roles list in some getRoutes() arms of App.tsx. A real dashboard lets a principal open the page first thing in the morning and see the state of the school in one screen.
What this is, in one line: turn /reports into a real analytics dashboard with KPI tiles, time-series, drilldowns, role-scoped tabs, and CSV export.
Scope (in / out)
In this plan:
- New
GET /api/reports/kpis,/timeseries,/cohorts,/exportendpoints inserver/src/controllers/reports.controller.js(extend the existing controller, no new file) - Full rewrite of
client/src/pages/admin/Reports.tsx— header filters, KPI tile row, tabbed sections, drilldown modals, CSV export - Add
principal(andbursar/hrwhere appropriate) to the allowed roles for/reportsin everygetRoutes()arm ofApp.tsx - Wire the
useEducationTerms()locale helpers and the existing date helpers — no hardcoded strings - Reuse Recharts, lucide, axios, AuditService — no new libraries
Out of this plan (explicitly):
- A second BI tool or external integration (Metabase, etc.)
- PDF export — CSV only this round
- Real-time streaming updates (websockets / SSE) — ship a refresh button + 5-minute in-memory cache
- Replacing the existing
/api/reports/summaryand/api/reports/dashboardendpoints — they stay; they're consumed elsewhere - Tightening JWT secret, CORS, request-size limit — separate open follow-ups
- Auto-creating the missing demo
sysadmin@school.comseed — carry-over from the 2026-07-19 portal-e2e plan, do it elsewhere if at all
Approach
Backend — extend server/src/controllers/reports.controller.js
The existing controller has summary, dashboard, enrollments, financial, attendance, grades, grades/subject-performance, engagement, export/:type. Keep all of them. Add four new endpoints and one new middleware-helper:
GET /api/reports/kpis?range=6m
Returns:
{
enrollments: { total, active, newThisTerm, byProgramme: { zimsec: N, igcse: N, ... } },
attendance: { rate30d, rate7d, trend: 'up'|'down'|'flat' },
academics: { avgMark, passRate, topSubject, bottomSubject },
finance: { collected30d, outstanding, overdueCount }, // role-gated to finance roles
staff: { activeTeachers, onLeave, pendingApprovals } // role-gated to hr/principal
}
The query plan for each KPI is a single SQL statement with one index scan. If a query takes >500ms on the seed, add a covering index; don't introduce a cache this round.
GET /api/reports/timeseries?metric=enrollments|attendance|marks|attendance&range=6m&granularity=week|month
Returns { points: [{ date, value, secondary? }] }. Granularity week returns ~26 points for 6m; month returns ~6. Use the existing attendance and enrollments tables — both have indexed date / created_at columns. Marks are bucketed by graded_at.
GET /api/reports/cohorts
Returns per-cohort performance:
[
{ cohortId, name, programme, level, academicYear, studentCount,
avgMark, passRate, attendanceRate, feeCollectionRate }
]
This endpoint assumes student_cohorts + cohort_students exist (Phase 1). If Phase 1 is delayed, gate this endpoint behind a feature flag and ship it later — the rest of the dashboard works without it.
GET /api/reports/export?type=overview|academics|cohorts|finance&format=csv&range=6m
Server-side CSV streaming using the same queries as the JSON endpoints. The existing GET /api/reports/export/:type route already does CSV for one report type — generalize it to accept type as a query param, support the four new types, and stream the response (don't buffer).
Auth / RBAC:
- Read paths:
school_admin | systems_admin | principal | bursar | hr(each role sees a different subset of tabs) - Use the
hasRolehelper from Phase 1 if it has landed; otherwise use the existingreq.user.rolechecks - The role-scoped data filtering happens server-side in the controller, not the client. The client just receives what its role is allowed to see.
Performance:
- Each KPI = one SQL statement, one index scan. Verify with
EXPLAIN QUERY PLANon the seed. - Time-series = one SQL statement with
GROUP BY date(?, 'week'|'month'). - Per-cohort table = one SQL with
LEFT JOIN student_cohorts + cohort_students + attendance + grades + invoices. If the JOIN is slow at the school's data size, denormalize later — but ship the JOIN first. - No new indexes this round. Add them only if a query is actually slow.
Frontend — full rewrite of client/src/pages/admin/Reports.tsx
The existing page already uses Recharts and lucide. Keep the import set; replace the layout.
Layout:
┌─────────────────────────────────────────────────────────────┐
│ Header: time-range picker | cohort filter | programme | │
│ class filter [Refresh] [Export ▼] │
├─────────────────────────────────────────────────────────────┤
│ KPI tile row (5 tiles) with sparklines │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Enrol│ │Atten│ │Acad │ │Finan│ │Staff│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
├─────────────────────────────────────────────────────────────┤
│ Tabs: [Overview] [Academics] [Cohorts] [Finance] [Staff] │
├─────────────────────────────────────────────────────────────┤
│ <Active tab content> │
│ │
└─────────────────────────────────────────────────────────────┘
Tab 1 — Overview (default)
- Enrollment area chart (line + area fill) from
/api/reports/timeseries?metric=enrollments - Attendance line from
/api/reports/timeseries?metric=attendance - Fee collection stacked bar (only visible to finance roles; backend returns 403 for others)
Tab 2 — Academics
- Grade distribution bar chart (A/B/C/D/F) — data already in
/api/reports/grades - Subject performance horizontal bar — data already in
/api/reports/grades/subject-performance - Top/bottom classes table (computed server-side from the same query)
- Click a class row → drilldown modal with that class's students ranked
Tab 3 — Cohorts (requires Phase 1)
- Table of cohorts from
/api/reports/cohortswith sortable columns - Click a row → drilldown modal with that cohort's students ranked
- If
student_cohortsdoesn't exist (Phase 1 hasn't shipped), this tab is hidden, not broken
Tab 4 — Finance (only visible to bursar | school_admin | systems_admin | principal)
- Reuse the existing
/api/reports/financialendpoint - Add a "fee collection rate per cohort" stacked bar (uses
/api/reports/cohortsdata)
Tab 5 — Staff (only visible to hr | school_admin | systems_admin | principal)
- Teachers on leave (count + list)
- Pending leave / payroll approvals (count + drilldown)
- Staff count by department (pie chart)
Filters (header):
- Time range: 7d / 30d / 90d / 6m / YTD / custom (date range picker)
- Cohort: dropdown populated from
/api/cohorts?is_active=1 - Programme: ZIMSEC / IGCSE / AS / A2 / primary / other (only enabled when cohorts are present)
- Class: dropdown populated from
/api/classes
Export button: top-right, dropdown of Overview / Academics / Cohorts / Finance. Each option hits /api/reports/export?type=...&format=csv&range=...&cohort=.... Browser downloads the file.
Routing fix (in client/src/App.tsx):
- In every
getRoutes()arm that lists/reportsas a route, addprincipalto theroles: []array. Also addbursarandhrwhere the role is allowed to see the relevant tab. - Verify with
git grep -n "path: '/reports'" client/src/App.tsxbefore merging.
Existing components to keep:
CustomTooltip(already in Reports.tsx) — reuse, no change- The 4–5 existing chart widgets — delete; they're replaced by the new tabs
New components:
KpiTile.tsx— small tile with a value, a label, a sparkline, a trend arrowDrilldownModal.tsx— generic modal that takes a title + a child for the bodyFilterBar.tsx— the header filter row, with the four filter controls + refresh + export
Sync / offline
This plan adds no new tables. Zero sync impact. All queries are read-only against existing tables. The dashboard works offline if the data was already synced (the existing data layer handles that), but we don't add new offline-first behavior for it this round.
Files to add / change
Backend (modify only)
server/src/controllers/reports.controller.js— addkpis,timeseries,cohorts, generalizeexport; add aroleScopedKpis(user)helper that filters the response by roleserver/src/index.js— no change (the new routes are added inside the existing controller)
Frontend (new)
client/src/components/KpiTile.tsxclient/src/components/DrilldownModal.tsxclient/src/components/FilterBar.tsx
Frontend (modify)
client/src/pages/admin/Reports.tsx— full rewrite (existing component, same path, mostly new JSX)client/src/App.tsx— addprincipal,bursar,hrto the/reportsallowed roles in everygetRoutes()armclient/src/components/Nav.tsx— no change (the Reports nav entry already exists for the relevant roles)
Docs / changelogs
.harness/changelogs/2026-07-22-analytics-dashboard.md.harness/docs/conventions.md— only if a new analytics pattern is introduced (likely not)
Constraints / decisions baked in
- Reuse, don't replace. Keep the existing
/api/reports/summaryand/api/reports/dashboardendpoints — other pages consume them. Only add the four new endpoints. - Role-scoped data, server-side. The backend filters by role; the client just renders what it gets. A 403 from a role-gated section is silent (the tab doesn't render), not a toast.
- Cohorts tab is conditional. If Phase 1 hasn't shipped when Phase 2 merges, the Cohorts tab is hidden, the rest of the dashboard works. Use a feature flag if needed; we don't need a real one if we're disciplined about merging order.
- No new libraries. Recharts, lucide, axios are all already in the bundle.
- No real-time updates. Refresh button + 5-minute in-memory cache (useState + setInterval, not Redux or any new state lib). On
?range=change, refetch. - No new tables, no sync impact. Read-only against existing schema.
- CSV only, no PDF. PDF export is a follow-up.
- The "73 TypeScript warnings" issue raised in the earlier critique isn't a blocker for this plan — we're not adding enough new TS to make the warnings materially worse, and the project compiles with
strict: falsealready. - No principal-specific portal. Principal shares the school_admin portal with role-scoped data, per existing convention.
Verification (acceptance bar)
This plan is "done" when all of the following pass:
- Migrations are not needed — this plan adds no new tables. (Confirm:
git diff --statonserver/src/database/is empty for this PR.) - Endpoints work:
GET /api/reports/kpis?range=6mreturns the documented shape with non-zero values from the seed.GET /api/reports/timeseries?metric=...&range=...&granularity=...returns 6+ points for 6m, 26+ points for 6m+week.GET /api/reports/cohortsreturns one row per cohort (or[]if Phase 1 hasn't shipped — the page handles both).GET /api/reports/export?type=...&format=csvdownloads a non-empty CSV.
- Dashboard renders for the right roles:
school_adminsees all 5 tabs.principalsees all 5 tabs (after the routing fix inApp.tsx).bursarsees Overview, Academics, Cohorts, Finance (no Staff).hrsees Overview, Academics, Cohorts, Staff (no Finance).student,parent,teacherget a 403 from the route, not a half-rendered page.
- Time-range filter actually changes the data — set range to 7d, then 6m, the values change.
- Cohort tab drilldown — click a cohort row, the drilldown modal shows the cohort's students ranked by some metric. The modal has a close button + Esc-to-close.
- CSV export downloads a non-empty file for each of Overview / Academics / Cohorts / Finance.
- Performance — every KPI query and the time-series query is <500ms on the seed. Use
EXPLAIN QUERY PLANto verify. If anything is slow, add a covering index and document the choice. - E2E (Playwright): one spec loads
/reportsasschool_admin, verifies all 5 tabs render, switches time range, drills into a cohort, exports a CSV. Existing portal-smoke tests still pass. - No regression: existing admin/principal dashboards, the exam-review module, and the finance module all still load and respond.
Execution order (this plan only)
PR 1: Backend endpoints (kpis, timeseries, cohorts, export)
PR 2: Frontend rewrite (Reports.tsx + KpiTile + DrilldownModal + FilterBar)
PR 3: Routing fix (App.tsx — add principal/bursar/hr to /reports) + E2E spec
PR 1 and PR 2 can be developed in parallel (different files). PR 3 is small and merges last because it depends on both the new endpoints and the new frontend.
If Phase 1 hasn't landed yet, PR 1's /cohorts endpoint is added with a featureFlagCohorts query param (default false) and the Cohorts tab in PR 2 is hidden until the flag is on. The rest of the dashboard ships.
Out of scope (explicitly)
- A second BI tool or external integration
- PDF export this round
- Real-time streaming updates
- Replacing the existing
/api/reports/summaryand/api/reports/dashboardendpoints - Auto-creating the missing demo
sysadmin@school.comseed - Migrating the existing
adminOrTeachermiddleware to thehasRolehelper from Phase 1 (the migration sweep is a separate follow-up) - Tightening JWT secret, CORS, request-size limit — separate open follow-ups
Parallelizability with Phase 3
This plan and Phase 3 (offboarding) share zero files:
- Different controllers (
reports.controller.jsvsoffboarding.controller.js) - Different frontend pages (
Reports.tsxvsOffboarding.tsx/OffboardingWizard.tsx/Alumni.tsx) - Different tables (no new tables here, vs
offboarding_records+offboarding_actions+users.archive_statusALTER in Phase 3) - Different sync impact (none here, vs 2 new tables in Phase 3)
After Phase 1 lands, a dev can pick up Phase 2 in one worktree and another dev can pick up Phase 3 in another. The only coordination is the merge order: either order is fine, since the two PRs don't touch the same code.