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 surface_for_role free fn, role-aware sidebar+topbar nav via new worker_role_slug context field, Keycloak seed updates (restrict jane.doe; add jane.supervisor + jane.qc), 2 new Playwright projects, 14 follow-up issues filed before commit.

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

  1. Two Georgia baselines: rulesets/georgia/composition/supervisor_dashboard.toml (11 panels) + analyst_dashboard.toml (6 panels).

  2. 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.

  3. get_dashboard surface dispatch + DashboardTemplate enum + 3 sibling #[derive(Template)] structs.

  4. Two new outer templates templates/dashboard/{supervisor,analyst}.html (near-clones of worker.html).

  5. Role-aware sidebar (nav_items) + topbar (topbar_nav_items) nav via new worker_role_slug: String context field; pre-auth surfaces pass String::new().

  6. Devstack Keycloak seed: restrict jane.doe to ["caseworker"] only; append jane.supervisor (role supervisor) + jane.qc (role quality_control).

  7. Playwright config: 2 new projects (supervisor, analyst) + 2 new spec files exercising the new surfaces + axe-core checks.

  8. CHANGELOG entry; parent plan row; architecture/services/CLAUDE.md/Antora doc updates.

  9. 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

worker-dashboard-at-a-glance-panel

0

12

unchanged (MR1)

Reused

2

supervisor-dashboard-team-queue-panel

1

6

6 / [6, 12]

NEW FU-7 placeholder

3

supervisor-dashboard-ievs-rollup-panel

1

6

6 / [6, 12]

NEW FU-8 placeholder

4

supervisor-dashboard-pending-hearings-panel

2

6

6 / [6, 12]

NEW REAL

5

supervisor-dashboard-sanctions-rollup-panel

2

6

6 / [6, 12]

NEW FU-9 placeholder

6

supervisor-dashboard-overpayment-rollup-panel

3

12

12 / [6, 12]

NEW FU-10 placeholder

7

worker-dashboard-recent-determinations-panel

4

6

unchanged

Reused

8

worker-dashboard-audit-events-panel

4

6

unchanged

Reused

9

worker-dashboard-recent-notices-panel

5

6

unchanged

Reused

10

worker-dashboard-cross-program-alerts-panel

5

6

unchanged

Reused

11

worker-dashboard-system-messages-panel

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

worker-dashboard-at-a-glance-panel

0

12

unchanged

Reused

2

analyst-dashboard-case-search-panel

1

12

12 / [6, 12]

NEW REAL

3

analyst-dashboard-reporting-rollup-panel

2

6

6 / [6, 12]

NEW FU-11 placeholder

4

analyst-dashboard-audit-export-panel

2

6

6 / [6, 12]

NEW FU-12 placeholder

5

worker-dashboard-recent-determinations-panel

3

6

unchanged

Reused

6

worker-dashboard-audit-events-panel

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 + Renewals when worker_role_slug == "qc"

  • Show Team Queue link when worker_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> gains data-surface="worker_dashboard" (for E2E selector symmetry with new surfaces).

  • Cell <div> gains id="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;.

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 to WorkerRole::Supervisor via precedence; post-MR2 she resolves to Caseworker, preserving MR1’s 12-panel E2E.

  • Append jane.supervisor (id …006, role supervisor) and jane.qc (id …007, role quality_control). Password password for 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

appeals/list.html

api/appeals.rs

list_appeals

worker_role_slug_for_session(&session)

applications/list.html

api/applications.rs

list_applications

same

applications/process.html

api/applications.rs

get_process_application

same

auth/sign_in.html

api/auth_sign_in.rs

sign_in_page

String::new() (pre-auth)

case_detail_summary.html

api/case_detail.rs

render_cross_program_summary (helper)

same

cases/detail.html

api/case_detail.rs

get_case_detail

same

cases/search.html

api/cases.rs

get_case_search

same

dashboard/worker.html

api/dashboard.rs (WorkerDashboardTemplate)

get_dashboard

role.0.clone()

dashboard/supervisor.html (NEW)

api/dashboard.rs

get_dashboard

same

dashboard/analyst.html (NEW)

api/dashboard.rs

get_dashboard

same

error.html

api/errors.rs

render_error_page

String::new() (may be pre-auth)

notices/list.html

api/notices.rs

list_notices

same as auth

renewals/queue.html

api/renewals.rs

get_renewal_queue

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) — mirror services/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

team_queue

supervisor-dashboard-team-queue

canopy-applications

["/v1/applications?assigned_to=team&limit=10"]

["supervisor"]

6 / all 5

ievs_rollup

supervisor-dashboard-ievs-rollup

canopy-verification

["/v1/verifications/ievs/rollup"]

["supervisor"]

6 / all 5

sanctions_rollup

supervisor-dashboard-sanctions-rollup

canopy-tanf

["/v1/tanf/sanctions/rollup"]

["supervisor"]

6 / ["tanf"]

pending_hearings

supervisor-dashboard-pending-hearings

canopy-appeals

["/v1/appeals/queue"]

["supervisor"]

6 / all 5

overpayment_rollup

supervisor-dashboard-overpayment-rollup

canopy-reporting

["/v1/reports/overpayments/summary"]

["supervisor"]

12 / all 5

analyst_case_search

analyst-dashboard-case-search

canopy-web

["/cases/search?q={query}"]

["qc"]

12 / all 5

analyst_reporting_rollup

analyst-dashboard-reporting-rollup

canopy-reporting

["/v1/reports/analyst/summary"]

["qc"]

6 / all 5

analyst_audit_export

analyst-dashboard-audit-export

canopy-security

["/v1/security/audit/export/signed-pdf"]

["qc"]

6 / all 5

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 5: Two Georgia composition baselines

Create rulesets/georgia/composition/supervisor_dashboard.toml (11 items) and analyst_dashboard.toml (6 items) per D2 / D3.

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_fetch arms (before the wildcard) calling {name}::fetch(clients, session, item).await

  • 8 new TypeId::of::<{snake}::{Pascal}Plugin> lines in assert_registered

  • Replace EXPECTED_PLUGIN_SLUGS with 3 bucket consts (WORKER/SUPERVISOR/ANALYST_DASHBOARD_PLUGIN_SLUGS)

  • Replace all_12_worker_dashboard_panels_registered with 3 sibling registration tests

  • Amend each_registered_panel_manifest_parses per D4

  • Add new baselines_panel_slugs_match_expected_buckets test

services/canopy-web/src/api/dashboard.rs:

  • Add surface_for_role free fn + 5 unit tests

  • Add SupervisorDashboardTemplate + AnalystDashboardTemplate structs + DashboardTemplate enum + render() impl

  • Handler: call surface_for_role, precompute slug/display strings, exhaustive match-construct the enum

  • Templates: add data-surface to 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 testMatch to /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=submitted fetcher (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 (added canopy-web::reporting InternalClient)

  • 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.html Askama 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) → String replaces all format!("{:?}", 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

  1. Verify git config core.hooksPath is .githooks.

  2. cargo xtask validate (canonical full gate).

  3. Pre-commit hook fires 2-stage Q1-Q8 token ritual; spawn fresh Explore subagent to verify Q1-Q8 against staged diff.

  4. Q1-Q8 answers inline to user (NOT in commit message).

  5. Pre-push hook runs FULL E2E (all 7 projects); must pass.

  6. Push + glab mr create + glab mr merge --yes; force-merge via glab api if 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.
Edit this page · default