# 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](./2026-07-22-phase1-cohorts-assignments.md) · [Phase 3 — offboarding](./2026-07-22-phase3-offboarding.md) · [superseded 7-feature draft](./2026-07-22-cohorts-assignments-analytics-offboarding.md) **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`, `/export` endpoints in `server/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` (and `bursar` / `hr` where appropriate) to the allowed roles for `/reports` in every `getRoutes()` arm of `App.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/summary` and `/api/reports/dashboard` endpoints — they stay; they're consumed elsewhere - Tightening JWT secret, CORS, request-size limit — separate open follow-ups - Auto-creating the missing demo `sysadmin@school.com` seed — 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: ```js { 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: ```js [ { 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 `hasRole` helper from Phase 1 if it has landed; otherwise use the existing `req.user.role` checks - 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 PLAN` on 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] │ ├─────────────────────────────────────────────────────────────┤ │ │ │ │ └─────────────────────────────────────────────────────────────┘ ``` **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/cohorts` with sortable columns - Click a row → drilldown modal with that cohort's students ranked - If `student_cohorts` doesn'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/financial` endpoint - Add a "fee collection rate per cohort" stacked bar (uses `/api/reports/cohorts` data) **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 `/reports` as a route, add `principal` to the `roles: []` array. Also add `bursar` and `hr` where the role is allowed to see the relevant tab. - Verify with `git grep -n "path: '/reports'" client/src/App.tsx` before 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 arrow - `DrilldownModal.tsx` — generic modal that takes a title + a child for the body - `FilterBar.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` — add `kpis`, `timeseries`, `cohorts`, generalize `export`; add a `roleScopedKpis(user)` helper that filters the response by role - `server/src/index.js` — no change (the new routes are added inside the existing controller) ### Frontend (new) - `client/src/components/KpiTile.tsx` - `client/src/components/DrilldownModal.tsx` - `client/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` — add `principal`, `bursar`, `hr` to the `/reports` allowed roles in every `getRoutes()` arm - `client/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/summary` and `/api/reports/dashboard` endpoints — 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: false` already. - **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: 1. **Migrations are not needed** — this plan adds no new tables. (Confirm: `git diff --stat` on `server/src/database/` is empty for this PR.) 2. **Endpoints work:** - `GET /api/reports/kpis?range=6m` returns 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/cohorts` returns one row per cohort (or `[]` if Phase 1 hasn't shipped — the page handles both). - `GET /api/reports/export?type=...&format=csv` downloads a non-empty CSV. 3. **Dashboard renders for the right roles:** - `school_admin` sees all 5 tabs. - `principal` sees all 5 tabs (after the routing fix in `App.tsx`). - `bursar` sees Overview, Academics, Cohorts, Finance (no Staff). - `hr` sees Overview, Academics, Cohorts, Staff (no Finance). - `student`, `parent`, `teacher` get a 403 from the route, not a half-rendered page. 4. **Time-range filter actually changes the data** — set range to 7d, then 6m, the values change. 5. **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. 6. **CSV export downloads a non-empty file** for each of Overview / Academics / Cohorts / Finance. 7. **Performance** — every KPI query and the time-series query is <500ms on the seed. Use `EXPLAIN QUERY PLAN` to verify. If anything is slow, add a covering index and document the choice. 8. **E2E (Playwright):** one spec loads `/reports` as `school_admin`, verifies all 5 tabs render, switches time range, drills into a cohort, exports a CSV. Existing portal-smoke tests still pass. 9. **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/summary` and `/api/reports/dashboard` endpoints - Auto-creating the missing demo `sysadmin@school.com` seed - Migrating the existing `adminOrTeacher` middleware to the `hasRole` helper 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.js` vs `offboarding.controller.js`) - Different frontend pages (`Reports.tsx` vs `Offboarding.tsx` / `OffboardingWizard.tsx` / `Alumni.tsx`) - Different tables (no new tables here, vs `offboarding_records` + `offboarding_actions` + `users.archive_status` ALTER 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.