geocrop-platform./apps/nextgen/.harness/plans/2026-07-22-phase2-analytics.md

256 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 45 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] │
├─────────────────────────────────────────────────────────────┤
│ <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/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 45 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.