Worker Portal Redesign — Stage 5 MR2: Supervisor + Analyst Dashboards
On this page
Tracking: epic &51 (#460) → Stage 5 MR2 (#496). Sibling: #495 (worker dashboard — Done 2026-05-22, !355, merge_commit 7c73112).
Branch root: feat/worker-portal-redesign-stage5-supervisor-analyst-dashboards.
GitLab MR labels: type::feature, priority::medium, program::infrastructure, service::web, workflow::ready, compliance::wcag-21-aa.
Status
| Surface | Status | Notes |
|---|---|---|
MR2 #496 supervisor + analyst dashboards |
Done — !356 |
Two new dashboard surfaces driven by the Stage-3 composition runtime. 8 panel plugins (5 supervisor + 3 analyst), 2 Georgia baselines, surface dispatch via |
Context
What we have today
Stage 5 MR1 landed the composition-driven 12-panel worker dashboard for caseworkers. services/canopy-web/src/api/dashboard.rs::get_dashboard hardcodes ComposableSurface::WorkerDashboard. All WorkerRole variants route through the same kit. The composition loader (crates/canopy-composition/src/loader.rs:200-274) already treats WorkerDashboard | SupervisorDashboard | AnalystDashboard identically — surfaces just need baselines + plugins.
WorkerRole enum at services/canopy-web/src/session.rs:16-22: Caseworker, EligibilitySpecialist, Supervisor, QualityControl, Admin. Precedence (session.rs:28-40): admin > supervisor > quality_control > eligibility_specialist > caseworker. jane.doe in devstack/keycloak/canopy-realm.json:560-564 has 3 roles → resolves to Supervisor today (a latent issue MR2 resolves).
What this MR delivers
-
Two Georgia baselines:
rulesets/georgia/composition/supervisor_dashboard.toml(11 panels) +analyst_dashboard.toml(6 panels). -
Eight new panel plugins (5 supervisor-only + 3 analyst-only) using the MR1 layout:
panels/{name}.rs+panels/{name}/Plugin.toml+templates/dashboard/panels/{name}.html. -
get_dashboardsurface dispatch +DashboardTemplateenum + 3 sibling#[derive(Template)]structs. -
Two new outer templates
templates/dashboard/{supervisor,analyst}.html(near-clones ofworker.html). -
Role-aware sidebar (
nav_items) + topbar (topbar_nav_items) nav via newworker_role_slug: Stringcontext field; pre-auth surfaces passString::new(). -
Devstack Keycloak seed: restrict
jane.doeto["caseworker"]only; appendjane.supervisor(rolesupervisor) +jane.qc(rolequality_control). -
Playwright config: 2 new projects (supervisor, analyst) + 2 new spec files exercising the new surfaces + axe-core checks.
-
CHANGELOG entry; parent plan row; architecture/services/CLAUDE.md/Antora doc updates.
-
14 follow-up issues filed BEFORE commit (FU-7..FU-20).
What this MR does NOT deliver
(Initial plan deferred more — see Step 8 for the honest reassessment that landed 9 of 14 FUs in-MR.)
-
Real upstream endpoints for IEVS rollup (FU-8 #533 — canopy-verification has no IEVS persistence today; ~2-3hr work).
-
Real upstream endpoint for analyst summary panel (FU-11 #536 — open design Q on what aggregates).
-
Signed-PDF audit log export endpoint (FU-12 #537 — Typst + JWS work, half-day+).
-
Admin dashboard surface (FU-14 #539 — needs product input).
-
Axe-core per-role consolidation (FU-17 #542 — current per-spec placement is arguably better).
-
Customize-my-dashboard UI (= #498).
-
Case detail (= #497).
Design
Decisions (locked)
Decision 1: Surface dispatch via free fn surface_for_role
Extract a free fn in dashboard.rs (sibling to parse_user_id) for unit-testability:
pub(crate) fn surface_for_role(role: &WorkerRole) -> ComposableSurface {
match role {
WorkerRole::Supervisor => ComposableSurface::SupervisorDashboard,
WorkerRole::QualityControl => ComposableSurface::AnalystDashboard,
WorkerRole::Caseworker
| WorkerRole::EligibilitySpecialist
| WorkerRole::Admin => ComposableSurface::WorkerDashboard,
}
}
Match-arms borrow &session.role so the value is not moved. Five unit tests (one per WorkerRole variant) live in mod surface_dispatch_tests. Admin falls through to WorkerDashboard for v1; dedicated admin surface filed as FU-14.
Decision 2: Supervisor baseline — 11 panels
| Order | Panel export slug | Row | Span | default_span / allowed_spans | Source |
|---|---|---|---|---|---|
1 |
|
0 |
12 |
unchanged (MR1) |
Reused |
2 |
|
1 |
6 |
|
NEW FU-7 placeholder |
3 |
|
1 |
6 |
|
NEW FU-8 placeholder |
4 |
|
2 |
6 |
|
NEW REAL |
5 |
|
2 |
6 |
|
NEW FU-9 placeholder |
6 |
|
3 |
12 |
|
NEW FU-10 placeholder |
7 |
|
4 |
6 |
unchanged |
Reused |
8 |
|
4 |
6 |
unchanged |
Reused |
9 |
|
5 |
6 |
unchanged |
Reused |
10 |
|
5 |
6 |
unchanged |
Reused |
11 |
|
6 |
12 |
unchanged |
Reused |
Row sums: 12, 12, 12, 12, 12, 12, 12 — all ≤ 12 per RowOverflow check at loader.rs:265.
Decision 3: Analyst baseline — 6 panels
| Order | Panel export slug | Row | Span | default_span / allowed_spans | Source |
|---|---|---|---|---|---|
1 |
|
0 |
12 |
unchanged |
Reused |
2 |
|
1 |
12 |
|
NEW REAL |
3 |
|
2 |
6 |
|
NEW FU-11 placeholder |
4 |
|
2 |
6 |
|
NEW FU-12 placeholder |
5 |
|
3 |
6 |
unchanged |
Reused |
6 |
|
3 |
6 |
unchanged |
Reused |
Row sums: 12, 12, 12, 12. Open design Q on analyst composition (per #496) parked; ship plausible v1 — design refines via Studio post-Stage-6.
Decision 4: Surface isolation — narrow required_roles + per-bucket manifest test + baseline-content test
New supervisor-only panels: required_roles = ["supervisor"]. New analyst-only: required_roles = ["qc"]. Reused worker panels keep the broad 4-role list (["eligibility_worker", "supervisor", "qc", "jurisdiction_admin"]).
each_registered_panel_manifest_parses amended to per-bucket check (narrow panels assert exact equality; broad worker panels assert all 4 roles present). NEW test baselines_panel_slugs_match_expected_buckets loads each Georgia baseline TOML and asserts items are a subset of (worker_reusable ∪ surface_exclusive). Catches baseline drift before E2E.
Silent-drop caveat: role-filter step 10 silently drops mismatches at runtime. The manifest + baseline tests are the unit-level guardrails; the E2E ordered-slug + absent-slug assertions are the surface-level guardrail.
Decision 5: Role-aware nav — sidebar + topbar, both blocks updated
New worker_role_slug: String context field (DISTINCT from the existing {% block worker_role %} at base.html:43 — that’s the human-readable display block, unchanged). Plumbed through 11 templates that extend base.html; pre-auth surfaces pass String::new() so all conditionals fall through to defaults.
Nav role rules (both nav_items sidebar block + topbar_nav_items block):
-
All roles: Dashboard, Case Search, Notices, Appeals (sidebar); Dashboard, Cases (topbar)
-
Hide
Applications+Renewalswhenworker_role_slug == "qc" -
Show
Team Queuelink whenworker_role_slug == "supervisor"(anchors to#panel-supervisor-dashboard-team-queue-panel)
Topbar’s pre-existing omission of Notices + Appeals is OUT OF SCOPE (filed FU-20).
Helper: worker_role_slug_for_session(&SessionData) → String in dashboard/role_map.rs. Tests INSIDE existing #[cfg(test)] mod tests block.
Decision 6: Panel cells gain id="panel-{slug}"; worker.html grid gains data-surface
worker.html (existing MR1 template) is updated — additive only:
-
Grid
<div>gainsdata-surface="worker_dashboard"(for E2E selector symmetry with new surfaces). -
Cell
<div>gainsid="panel-{{ panel.slug }}"(so the supervisor Team Queue nav anchor lands on the panel cell).
Same shape replicated in new supervisor.html + analyst.html with their respective data-surface values.
Decision 7: Three Template structs + DashboardTemplate enum + exhaustive match
WorkerDashboardTemplate / SupervisorDashboardTemplate / AnalystDashboardTemplate
— identical 7-field set
— different #[template(path = ...)]
DashboardTemplate enum with Worker/Supervisor/Analyst variants + render() impl
Fields: panels: Vec<RenderedPanel>, branding: BrandingConfig, is_sidebar: bool, active_nav: String, worker_name: String, worker_role: String, worker_role_slug: String.
Handler construction order: compute worker_role_slug, worker_role, worker_name BEFORE moving session fields. Exhaustive match surface { Worker ⇒ …, Supervisor ⇒ …, Analyst ⇒ …, other ⇒ unreachable!("get_dashboard cannot produce {other:?}") }. RenderedPanel is not Clone; each match arm consumes the panels vec exclusively.
Decision 8: System defaults stay empty (mirrors MR1 D8)
crates/canopy-composition/defaults/{supervisor,analyst}_dashboard.json already ship as {"shell": "grid", "items": []}. MR2 does NOT change them. Georgia baselines do all the work.
Decision 9: pending_hearings = client-side filter on /v1/appeals/queue
The upstream appeals_queue handler (services/canopy-appeals/src/api/mod.rs:284-292) takes no params and returns up to 100 appeals; the underlying store::list_appeals_queue (store.rs:92-100) already filters WHERE active = true AND status IN ('pending', 'scheduled') server-side. So pending_hearings only needs a client-side filter on hearing_scheduled_date.is_some() to surface appeals with a scheduled hearing.
Fetch pattern matches MR1’s panels/audit_events.rs:36-90: clients.appeals.get::<Vec<AppealRequest>>("/v1/appeals/queue").await. No with_service_identity per-fetch — service identity is pre-applied once in get_dashboard at dashboard.rs:53 before fan-out.
canopy-web/Cargo.toml gains canopy-contracts-appeals = { workspace = true }. Import: use canopy_contracts_appeals::appeals::AppealRequest;.
Decision 10: analyst_case_search = hx-get to existing /cases/search
Panel template renders a search form + empty results div. Form submission posts via hx-get to existing GET /cases/search?q=… route (api/cases.rs:75-85) which returns an htmx-swap fragment. Fetcher returns an empty-state Template; no initial network call.
Decision 11: Keycloak seed — restrict jane.doe + add jane.supervisor + jane.qc
Edit devstack/keycloak/canopy-realm.json:
-
jane.doe.realmRoles: change from["caseworker", "eligibility_specialist", "supervisor"]to["caseworker"]only. Pre-MR2 jane.doe resolved toWorkerRole::Supervisorvia precedence; post-MR2 she resolves toCaseworker, preserving MR1’s 12-panel E2E. -
Append
jane.supervisor(id…006, rolesupervisor) andjane.qc(id…007, rolequality_control). Passwordpasswordfor both, matching existing test-user convention.
Decision 12: Delete auth/caseworker.json only
tests/e2e/auth/setup.ts:24-46 reuses cached storage state via a probe at line 33-37 that verifies .page-title visibility (cache-validity signal). Pre-MR2 cached state for jane.doe carries the old 3-role JWT → routes her to SupervisorDashboard post-MR2 and breaks MR1 tests. Fix: delete tests/e2e/auth/caseworker.json once as part of the MR2 commit; setup.ts re-creates on next run via fall-through login.
bob-smith.json is NOT deleted (bob.smith roles unchanged).
Decision 13: Audit emission expands to 2 new surfaces (no consumer impact)
load_composition emits audit_emitter.emit_render(juris, role, user_id, surface.as_snake_case(), version) on every render (cache hit at loader.rs:146-154 + fresh resolve at loader.rs:306-314). canopy-web wires AmqpAuditEmitter at main.rs:75-86. Post-MR2 the envelope carries surface = "supervisor_dashboard" | "analyst_dashboard" (in addition to existing). No downstream filter; no consumer-side change needed. CHANGELOG note.
Steps
Step 1: Devstack Keycloak seed updates
Edit devstack/keycloak/canopy-realm.json per D11. Reload via cargo xtask dev restart then cargo xtask seed. (dev restart wipes postgres + reimports Keycloak realm; seed is the top-level command at xtask/src/main.rs:26, NOT dev seed.)
Step 2: worker_role_slug_for_session helper + base.html nav
Append worker_role_slug_for_session(session: &SessionData) → String to services/canopy-web/src/dashboard/role_map.rs INSIDE the existing #[cfg(test)] mod tests block. Add 3 tests (supervisor → "supervisor", qc → "qc", caseworker → "eligibility_worker") using a SessionData fixture (8 fields per session.rs:67-102).
Replace nav_items block (base.html:31-39) + topbar_nav_items block (base.html:81-87) per D5.
Step 3: Plumb worker_role_slug through 11 base.html-extending templates
| Template | Struct file | Handler/builder (verified) | Slug source |
|---|---|---|---|
|
|
|
|
|
|
|
same |
|
|
|
same |
|
|
|
|
|
|
|
same |
|
|
|
same |
|
|
|
same |
|
|
|
|
|
|
|
same |
|
|
|
same |
|
|
|
|
|
|
|
same as auth |
|
|
|
same as auth |
Compute slug BEFORE moving session fields. 11 existing structs touched + 2 new dashboard structs = 13 rows.
Step 4: Create 8 new panel plugins
Mirror MR1 layout: panels/{name}.rs (with SPDX header) + panels/{name}/Plugin.toml (with SPDX header) + templates/dashboard/panels/{name}.html (with {# SPDX-License-Identifier: AGPL-3.0-or-later #} first line).
Reference patterns:
-
REAL panels (
pending_hearings,analyst_case_search) — mirrorservices/canopy-web/src/dashboard/panels/audit_events.rs:1-90. -
FU-placeholder panels (6 of 8) — mirror
services/canopy-web/src/dashboard/panels/pending_verifications.rs.
| name | slug | data.source | endpoints | required_roles | default_span / programs |
|---|---|---|---|---|---|
|
|
canopy-applications |
|
|
|
|
|
canopy-verification |
|
|
|
|
|
canopy-tanf |
|
|
|
|
|
canopy-appeals |
|
|
|
|
|
canopy-reporting |
|
|
|
|
|
canopy-web |
|
|
|
|
|
canopy-reporting |
|
|
|
|
|
canopy-security |
|
|
|
All panels use allowed_spans = [6, 12]. All panel templates use Orchard primitives (o::panel_frame, o::empty_state) — NO raw .panel divs.
services/canopy-web/Cargo.toml gains canopy-contracts-appeals = { workspace = true }.
Step 6: Update panels/mod.rs + api/dashboard.rs
services/canopy-web/src/dashboard/panels/mod.rs:
-
8
pub mod {name};declarations -
8 new
dispatch_fetcharms (before the wildcard) calling{name}::fetch(clients, session, item).await -
8 new
TypeId::of::<{snake}::{Pascal}Plugin>lines inassert_registered -
Replace
EXPECTED_PLUGIN_SLUGSwith 3 bucket consts (WORKER/SUPERVISOR/ANALYST_DASHBOARD_PLUGIN_SLUGS) -
Replace
all_12_worker_dashboard_panels_registeredwith 3 sibling registration tests -
Amend
each_registered_panel_manifest_parsesper D4 -
Add new
baselines_panel_slugs_match_expected_bucketstest
services/canopy-web/src/api/dashboard.rs:
-
Add
surface_for_rolefree fn + 5 unit tests -
Add
SupervisorDashboardTemplate+AnalystDashboardTemplatestructs +DashboardTemplateenum +render()impl -
Handler: call
surface_for_role, precompute slug/display strings, exhaustive match-construct the enum -
Templates: add
data-surfaceto all 3 grids +id="panel-{slug}"to all 3 cells
Step 7: Playwright config + 2 new spec files + cache invalidation
Delete tests/e2e/auth/caseworker.json once.
tests/e2e/auth/setup.ts — append jane.supervisor and jane.qc to the users array with stateFiles auth/supervisor.json + auth/analyst.json.
tests/e2e/playwright.config.ts:
-
Update caseworker
testMatchto/specs\/(?!rbac|screenshots|dashboard-supervisor|dashboard-analyst).*\.spec\.ts/ -
Append 2 projects (supervisor, analyst), each with dedicated storageState + testMatch.
Create tests/e2e/specs/dashboard-supervisor.spec.ts + dashboard-analyst.spec.ts with:
-
Ordered slug assertion using existing pattern (
expect(cells).toHaveCount(N);for i in 0..N: expect(cells.nth(i)).toHaveAttribute('data-panel-slug', expected[i])) -
Absent-slug list assertion (
expect(…).toHaveCount(0)) -
Team Queue link + anchor assertion (supervisor only)
-
axe-core check using default import + tags
['wcag2a', 'wcag2aa', 'section508']+ critical-only fail
Step 8: File + close 14 follow-up issues
14 FUs filed pre-commit. Per the no-deferral discipline, the user pushed back on the initial "defer all 14" plan. Honest reassessment landed 9 of 14 in MR2:
Landed in MR2 (9):
-
FU-7 (#532): team-queue panel — real
/v1/applications?status=submittedfetcher (no new endpoint needed; cross-worker queue IS the existing endpoint) -
FU-9 (#534): canopy-tanf sanctions rollup endpoint at
/v1/tanf/sanctions/rollup+ populated panel -
FU-10 (#535): canopy-reporting overpayment summary endpoint at
/v1/reporting/overpayments/summary+ populated panel (addedcanopy-web::reportingInternalClient) -
FU-13 (#538): /team-queue full-page route with 403 gate for non-Supervisor/Admin roles
-
FU-15 (#540): renamed
WorkerRole::QualityControl→::Analyst; Keycloak role string + idp.toml role + all Plugin.toml["qc"]→["analyst"] -
FU-16 (#541): extracted
templates/dashboard/_panel_grid.htmlAskama macro; all 3 dashboards{% call g::grid(panels, surface) %} -
FU-18 (#543): screenshot capture (added to dashboard-supervisor.spec.ts + dashboard-analyst.spec.ts)
-
FU-19 (#544):
worker_role_display(&WorkerRole) → Stringreplaces allformat!("{:?}", session.role)sites -
FU-20 (#545): topbar nav reaches parity with sidebar (Notices + Appeals)
Genuinely deferred (5) — scope corrections filed as issue comments:
-
FU-8 (#533): IEVS rollup endpoint — canopy-verification has NO IEVS match persistence today (pass-through to adapters); real rollup needs schema migration + persistence layer + aggregation. ~2-3 hours.
-
FU-11 (#536): analyst reporting rollup — open design Q on what aggregates (determinations? backlog? federal report status?). Defer until spec lands.
-
FU-12 (#537): signed-PDF audit log export — Typst template + JWS PDF signing + chain export. Half-day+ work spanning crypto + notice-pipeline.
-
FU-14 (#539): admin_dashboard surface — needs product input on panel set.
-
FU-17 (#542): axe-core per-role consolidation — current per-spec placement is arguably better than centralized.
Step 9: Docs + CHANGELOG + memory
CHANGELOG === Changed entries (4 terse bullets): worker portal GET / dispatches per WorkerRole; sidebar+topbar nav adapt to role; devstack jane.doe roles restricted, 2 new test users; composition audit envelope carries 2 new surface values.
Parent plan adoc Stage 5 MR2 row: Done (<DATE-AT-COMMIT>) — !<MR-AT-COMMIT> merged to main as <merge-commit>. Plus updates to architecture.md, services.md, local-dev.md, CLAUDE.md, canopy-web.adoc, nav.adoc, and memory files (project_epic_51_in_progress.md + MEMORY.md).
Step 10: Pre-commit + push + merge
-
Verify
git config core.hooksPathis.githooks. -
cargo xtask validate(canonical full gate). -
Pre-commit hook fires 2-stage Q1-Q8 token ritual; spawn fresh Explore subagent to verify Q1-Q8 against staged diff.
-
Q1-Q8 answers inline to user (NOT in commit message).
-
Pre-push hook runs FULL E2E (all 7 projects); must pass.
-
Push +
glab mr create+glab mr merge --yes; force-merge viaglab apiif pipeline blocks (user-stable workflow per MR1 precedent).
Verification
cargo nextest run -p canopy-web -p canopy-composition
cargo xtask validate
cargo xtask dev restart
cargo xtask seed
# Browser at http://localhost:8080:
# - jane.doe / password → 12-panel worker dashboard (data-surface="worker_dashboard")
# - jane.supervisor / password → 11-panel supervisor dashboard
# - jane.qc / password → 6-panel analyst dashboard (Applications + Renewals absent)
# - admin / password → worker dashboard (D1 fallthrough)
cd tests/e2e && pnpm playwright test
# Expect: 7 projects all green.