Worker Portal Redesign — Stage 5 MR1: Composition-driven worker dashboard
On this page
Tracking: epic &51 (#460) → Stage 5 MR1 (#495). Stage-5 sibling issues: #496 (supervisor + analyst), #497 (case detail), #498 (customize-my-dashboard).
Branch root: feat/worker-portal-redesign-stage5-worker-dashboard.
GitLab MR labels: type::feature, priority::medium, program::infrastructure, service::web, workflow::ready, compliance::wcag-21-aa.
Status
| Surface | Status | Notes |
|---|---|---|
MR1 #495 worker dashboard (12-panel kit, composition-driven) |
Done (2026-05-24) — !355 |
First canopy-web surface to be fully composition-driven. 12 panel plugins + populated Georgia baseline + populated system defaults + new dashboard handler that calls composition loader + per-panel Askama partials + dashboard.spec.ts selector updates. |
Context
What we have today
services/canopy-web/src/api/dashboard.rs is a monolithic 456-line handler. It hardcodes 4 stats + 5 program cards + 1 work queue + 1 activity feed; templates/dashboard.html hardcodes the corresponding HTML structure.
Stage 3 MR1 shipped the composition runtime (crates/canopy-composition with load_composition, PluginSource trait, #[canopy_plugin] macro, composition_documents migration). System defaults at crates/canopy-composition/defaults/worker_dashboard.json ship empty items — mr1_defaults_ship_empty_items test (defaults.rs:78-94) currently asserts this across ALL 5 surfaces.
CompileTimePluginSource (crates/canopy-composition/src/source.rs) is wired to the CANOPY_PLUGINS linkme distributed slice, but no plugins register into it today. The slice is empty at runtime.
Stage 3 MR2 shipped HTTP override APIs (services/canopy-web/src/api/composition.rs). They write to composition_documents for live/role/user layers. Stage 5 is the first MR where those APIs target a real surface.
What this MR delivers
-
12 panel plugins registered via
#[canopy_plugin](panel slugs listed in Decision 1). -
Per-panel
Plugin.tomlmanifest + Askama partial template + Rust data fetcher. -
New worker dashboard template at
services/canopy-web/templates/dashboard/worker.htmlthat consumesComposedSurfaceand renders panels per the composition loader’s ordering + spans. -
New dashboard handler that calls
composition_loader.load_composition(WorkerDashboard, jurisdiction, role, user_id, idp), fans out per-panel fetchers in parallel, and rendersworker.html. Replaces the existingget_dashboardhandler at theGET /route — URL preserved. -
Populated
crates/canopy-composition/defaults/worker_dashboard.json(12 items with default spans) — replaces the empty fixture. Per-surfaceempty_itemsinvariant retained for the other 4 surfaces. -
Populated
rulesets/georgia/composition/worker_dashboard.toml(identical to system defaults in v1; Georgia ships with the canopy-team default; jurisdictions edit the file to deviate). -
Updated
tests/e2e/specs/dashboard.spec.ts(test-by-test plan in Step 5; some tests stay, some rewrite, some delete). -
New unit + integration tests: per-panel state assertions (3 states × 12 panels), composition→handler→render integration test, axe-core WCAG 2.1 AA pass.
What this MR does NOT deliver
-
Supervisor + analyst dashboards (= #496; role overrides applied via Stage 3 MR2’s role-layer APIs).
-
Customize-my-dashboard UI (= #498; user-layer delta writes via Stage 3 MR2’s user-me APIs).
-
Case-detail 3-shell/13-section refactor (= #497).
-
Real upstream endpoints for panels that lack a data source today (Decision 3 lists each panel’s source; follow-up issues filed for missing endpoints rather than deferred in-MR).
-
htmx refresh-button polling. Panels render server-side once. Loading state is dropped from each panel’s
required_statesdeclaration (Decision 6); a follow-up issue ships the refresh affordance + loading-state render path.
Design
Decisions (locked)
Decision 1: 12 plugin slugs + 12 panel export slugs
ADR-021 requires per-export slugs distinct from the plugin slug. Plugin slugs use worker-dashboard-{name}; the single panel each plugin exports uses worker-dashboard-{name}-panel. Composition references the panel export slug. The dispatcher matches on the panel export slug (not the plugin slug).
Plugin slug (used in #[canopy_plugin(slug = "…")]) |
Panel export slug (referenced from composition) |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
All slugs match ADR-021 regex ^[a-z][a-z0-9-]*[a-z0-9]$. All slugs share the worker-dashboard- prefix to disambiguate from future supervisor/analyst panel re-uses.
Decision 2: Default panel ordering + spans + rows (resolves both open Qs on #495)
Most-actionable surfaces first. All spans in {1, 2, 3, 4, 6, 12} per ADR-021’s BREAKPOINT_SPANS (crates/canopy-composition/src/manifest.rs:107). Each row sums to exactly 12.
row is a required field on ComposedItem (crates/canopy-composition/src/types.rs:122 — pub row: u8). The loader groups items by row to enforce the per-row 12-span maximum (loader.rs:265). Every defaults/baseline item MUST declare row.
| Order | Panel export slug | Row | Span | Row note |
|---|---|---|---|---|
1 |
|
0 |
12 |
Hero row |
2 |
|
1 |
6 |
Row 1 left (6+6=12) |
3 |
|
1 |
6 |
Row 1 right |
4 |
|
2 |
6 |
Row 2 left (6+6=12) |
5 |
|
2 |
6 |
Row 2 right |
6 |
|
3 |
6 |
Row 3 left (6+6=12) |
7 |
|
3 |
6 |
Row 3 right |
8 |
|
4 |
4 |
Row 4 (4+4+4=12) |
9 |
|
4 |
4 |
Row 4 |
10 |
|
4 |
4 |
Row 4 |
11 |
|
5 |
6 |
Row 5 left (6+6=12) |
12 |
|
5 |
6 |
Row 5 right |
Per-panel default_span matches the table value above; allowed_spans = [3, 4, 6, 12] for every list-style panel (positions 2-12); allowed_spans = [12] only for the hero (position 1). The chosen default_span is always a member of allowed_spans (per manifest.rs:204 validation).
Decision 3: Panel → data source mapping
Each panel’s [data] block declares source + endpoints (one or more upstream URLs with {param} substitutions). The handler resolves {user_id} from the parsed-UUID worker session (SessionData.worker_id).
Per crates/canopy-composition/src/manifest.rs:189-191, endpoints array MUST be non-empty (EmptyEndpoints rejection). Panels whose real endpoint is not yet wired declare a placeholder endpoint string in their manifest AND their Rust fetcher constructs the panel Template struct with state = "empty" instead of hitting the network. Replacing the placeholder is a one-line edit when the FU lands.
| Panel | Upstream service | Endpoint(s) | Has endpoint today? |
|---|---|---|---|
|
(aggregator) |
4 existing endpoints already in current |
Yes |
|
(aggregator) |
3 existing endpoints (apps + renewals + appeals) per current |
Yes |
|
canopy-applications |
|
Yes |
|
canopy-verification |
placeholder: |
No — FU-1 |
|
canopy-renewals |
placeholder: |
No — FU-2 |
|
canopy-wic |
placeholder: |
No — FU-3 |
|
(aggregator) |
5 program services' |
Yes |
|
canopy-notices |
|
Yes |
|
canopy-verification |
placeholder: |
No — FU-4 |
|
canopy-eligibility |
placeholder: |
No — FU-5 |
|
canopy-security |
|
Yes |
|
canopy-web (self) |
placeholder: |
No — FU-6 |
Follow-ups filed BEFORE commit per feedback_no_deferral_accountability. See Step 8.
Decision 4: Built-in plugins live in services/canopy-web/src/dashboard/panels/; manifests use crate-relative paths
The #[canopy_plugin] proc-macro expands include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", manifest)) — CARGO_MANIFEST_DIR is the crate root (services/canopy-web/). Per-panel Plugin.toml files must be referenced via crate-relative paths.
Layout:
services/canopy-web/
├─ src/
│ └─ dashboard/
│ └─ panels/
│ ├─ mod.rs (RenderedPanel, dispatch_fetch)
│ ├─ at_a_glance.rs (#[canopy_plugin] + fetcher)
│ ├─ at_a_glance/
│ │ └─ Plugin.toml
│ ├─ my_queue.rs
│ ├─ my_queue/
│ │ └─ Plugin.toml
│ ... (12 panels total)
├─ templates/
│ └─ dashboard/
│ ├─ worker.html
│ └─ panels/
│ ├─ at_a_glance.html
│ ├─ my_queue.html
│ ... (12 partials)
#[canopy_plugin] attribute per panel:
#[canopy_plugin(
slug = "worker-dashboard-at-a-glance",
manifest = "src/dashboard/panels/at_a_glance/Plugin.toml",
)]
pub struct AtAGlancePlugin;
The path is verbose but unambiguous and survives the macro’s CARGO_MANIFEST_DIR resolution. Each plugin struct is a unit struct; the macro doesn’t depend on it having fields.
Decision 5: Per-panel fetcher renders to String; handler iterates RenderedPanel
Per-panel partials cannot share a single panel context because askama {% include %} renders in the parent’s context. To let each panel template declare exactly the fields it needs, each panel is its own #[derive(Template)] struct that renders to a String in Rust; the parent worker.html only emits the pre-rendered HTML via {{ panel.html|safe }}.
Each panel’s panels/{slug_underscored}.rs exposes:
pub async fn fetch(
clients: &ServiceClients,
session: &SessionData,
composed_item: &ComposedItem,
) -> RenderedPanel;
The fetcher does:
-
Hit upstream endpoint(s) (or skip for FU placeholders) →
Result<view, Err>. -
Build the panel’s own
#[derive(Template)]struct (fields specific to that panel: e.g.AtAGlancePanelTemplate { state, label, count_text, error_text, pending_applications, renewals_due, appeals_pending, interim_contacts_due }). -
Set
state = "populated" | "empty" | "error"per outcome (see Decision 6 —&'static str, NOT enum). -
Precompute
count_text: Stringfrom the count,error_text: Stringfrom the error. Convention for these panels: every value referenced in a panel partial is precomputed in the fetcher; no method calls in panel templates. Note this is a local convention, not an askama-0.15 limitation — askama DOES support method calls (e.g.work_queue.is_empty()in currentdashboard.html:55) — but precomputing keeps panel partials shape-pure and helps tests assert against handler-side data. -
Call
.render()(returnsResult<String, askama::Error>) and wrap intoRenderedPanel. Use a helper to convert a render-failure into an error-state panel rather thanunwrap/?propagation:fn finalize<T: Template>( tmpl: T, item: &ComposedItem, ) -> RenderedPanel { let html = match tmpl.render() { Ok(s) => s, Err(e) => { tracing::error!(slug = %item.item.0, error = %e, "panel template render failed"); // Render the unknown_panel error fallback inline so the // dashboard still ships a structurally-valid panel cell. unknown_panel::render_error_html(&item.item.0, "Template render failure") } }; RenderedPanel { slug: item.item.0.clone(), row: item.row, span: item.span, html, } }Fetchers wrap their template call as
finalize(MyPanelTemplate { … }, composed_item). Nounwrapoutside tests (Q2 hard rule); errors degrade gracefully to the unknown_panel error fallback (Decision 7 / Step 1 filepanels/unknown_panel.rs).
RenderedPanel shape (in panels/mod.rs):
pub struct RenderedPanel {
pub slug: String,
pub row: u8,
pub span: u8,
pub html: String, // pre-rendered panel HTML
}
No PanelData / PanelState enum is needed — state lives inside each panel’s Template struct as state: &'static str.
The handler in services/canopy-web/src/api/dashboard.rs maps each composed.items[i].item.0 to its fetcher via a match in services/canopy-web/src/dashboard/panels/mod.rs::dispatch_fetch:
pub async fn dispatch_fetch(
composed_item: &ComposedItem,
clients: &ServiceClients,
session: &SessionData,
) -> RenderedPanel {
let slug = composed_item.item.0.as_str();
match slug {
"worker-dashboard-at-a-glance-panel" =>
at_a_glance::fetch(clients, session, composed_item).await,
"worker-dashboard-my-queue-panel" =>
my_queue::fetch(clients, session, composed_item).await,
// ... 10 more arms ...
unknown =>
unknown_panel::render(composed_item, unknown), // synth RenderedPanel with state=error
}
}
All 12 dispatches in a single render are fanned out via futures::future::join_all. Service identity: per ADR-019 the handler calls clients.with_service_identity(&svc_token).await ONCE before the fan-out (mirroring current dashboard.rs:107). Each panel’s Plugin.toml declares data.auth = "service_class" (v1 — user-JWT pass-through deferred per ADR-019 per-panel auth-mode hook). Unknown-slug fallback lives in a single unknown_panel partial that renders an error_block (unknown_panel::render precomputes the error text + state).
Note on data.timeout_ms: the manifest field is declarative for v1; the underlying InternalClient has a hardcoded 5-second reqwest timeout (services/canopy-web/src/clients.rs:37). Wiring per-panel timeout from manifest is FU-9.
Decision 6: Per-panel #[derive(Template)] struct + 3-state contract
Each panel partial has its OWN #[derive(Template)] struct (no shared panel context). All strings the template references are precomputed in the fetcher — see Decision 10’s "convention for these panels" note on the precompute discipline.
Common fields every per-panel Template struct carries:
pub struct {Name}PanelTemplate {
pub state: &'static str, // "populated" | "empty" | "error"
pub label: String, // panel display label (e.g. "Pending applications")
pub count_text: String, // precomputed badge text (empty string if no count)
pub error_text: String, // precomputed error message (empty string if no error)
// ... panel-specific fields for the "populated" branch ...
}
Each templates/dashboard/panels/{slug_underscored}.html:
{% import "_primitives/orchard.html" as o %}
{% call o::panel_frame(label=label, count=count_text, accent="default") %}
{% if state == "error" %}
{% call o::error_block(
title="Couldn't load",
body=error_text,
last_known_at="",
retry_url="",
retry_target="",
status_href=""
) %}{% endcall %}
{% else if state == "empty" %}
{% call o::empty_state(
title="(panel-specific empty title — declared per-panel)",
body="(panel-specific empty body — declared per-panel)",
cta_label="",
cta_href=""
) %}{% endcall %}
{% else %}
(panel-specific populated render — uses panel-specific fields directly)
{% endif %}
{% endcall %}
The fields state, label, count_text, error_text are not nested under a panel object — they’re top-level on the per-panel Template struct. This is what makes the per-panel struct independent of the parent worker.html template.
required_states per panel manifest declares ["empty", "error", "populated"]. Loading state is dropped from manifests because v1 renders server-side once; htmx refresh + loading skeleton land in a Stage-5 follow-up issue (FU-7).
Stage 1 primitive signatures (verified at services/canopy-web/templates/_primitives/orchard.html):
-
panel_frame(label="", count="", accent="default", dense=false)— notecountis a string, treated as truthy when non-empty ({% if count != "" %}). -
empty_state(title="", body="", cta_label="", cta_href="") -
error_block(title, body, last_known_at="", retry_url="", retry_target="", status_href="") -
skeleton_row(columns=4)(referenced by FU-7, NOT this MR)
Decision 7: Handler flow with correct loader signature + required global extension rewiring
The composition loader’s actual signature (crates/canopy-composition/src/loader.rs:111-119):
pub async fn load_composition(
&self,
pool: &PgPool,
jurisdiction: &JurisdictionSlug,
role: &RoleSlug,
user_id: Option<&UserId>,
surface: ComposableSurface,
idp: &IdpDocument,
) -> Result<Arc<ComposedSurface>, CompositionLoadError>
Existing canopy-web composition handlers (api/composition.rs:562) source the idp via composition_loader.idp_for(&juris).await (this method does exist on CompositionLoader).
Required main.rs rewiring — three extensions are currently NOT globally available on the outer router and MUST be added before the dashboard handler can compile:
-
Extension<Arc<CompositionState>>— today scoped tocomposition_routeronly (main.rs:182). Move the.layer(axum::Extension(composition_state.clone()))to the outerrouter(alongsideidp_runtime/service_clients/theme_config/etc layers atmain.rs:230-244). Keep the existing composition_router layer too — Extensions can stack identically; downstream handlers see the same Arc. -
Extension<DbPool>—boot.db: canopy_db::DbPoolis the typed wrapper. Add.layer(axum::Extension(boot.db.clone()))to the outer router. Handler calls.inner()to get the&PgPoolthe loader needs. -
Extension<Arc<WebConfig>>— todaysvc_config: WebConfigis consumed during boot and dropped. Clone+Arc it before Boot drops it:let web_config_ext = Arc::new(svc_config.clone());, then.layer(axum::Extension(web_config_ext)).
These rewirings live in Step 3’s main.rs section (Step 3).
pub async fn get_dashboard(
AuthenticatedWorker(session): AuthenticatedWorker,
Extension(theme): Extension<Arc<ThemeConfig>>,
Extension(clients): Extension<Arc<ServiceClients>>,
Extension(svc_token): Extension<canopy_auth::ServiceTokenSource>,
Extension(db): Extension<canopy_db::DbPool>,
Extension(comp): Extension<Arc<crate::api::composition::CompositionState>>,
Extension(web_config): Extension<Arc<WebConfig>>,
) -> Result<Html<String>, axum::http::StatusCode> {
// 1. Apply service identity once, share across fan-out.
let clients = clients.with_service_identity(&svc_token).await;
// 2. Source the IdpDocument for this jurisdiction.
let juris = JurisdictionSlug(web_config.jurisdiction.clone());
let idp = comp.composition_loader.idp_for(&juris).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 3. WorkerRole → RoleSlug per Decision 9.
let role = role_slug_for_worker(&session.role);
let user_id = parse_user_id(&session.worker_id); // Option<UserId>
// 4. Load composition for WorkerDashboard surface.
// `db.inner()` returns &PgPool (the wrapper-to-naked-sqlx accessor).
let composed = comp.composition_loader.load_composition(
db.inner(), &juris, &role, user_id.as_ref(),
ComposableSurface::WorkerDashboard, &idp,
).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 5. Empty composition guard — render diagnostic single-panel.
if composed.items.is_empty() {
tracing::error!("WorkerDashboard composition resolved with 0 items");
return Ok(Html(unknown_panel::render_dashboard_empty()));
}
// 6. Fan-out per-panel fetchers in parallel; each returns RenderedPanel.
let fetches = composed.items.iter().map(|item| {
let session = session.clone();
let clients = clients.clone();
async move { dispatch_fetch(item, &clients, &session).await }
});
let panels: Vec<RenderedPanel> = futures::future::join_all(fetches).await;
// 6. Render the outer dashboard, embedding pre-rendered panel HTML.
let tmpl = WorkerDashboardTemplate {
panels,
branding: theme.branding.clone(),
is_sidebar: theme.is_sidebar(),
active_nav: "dashboard".to_string(),
worker_name: session.worker_name.clone(),
worker_role: format!("{:?}", session.role),
};
tmpl.render().map(Html).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
WorkerDashboardTemplate struct fields are explicit (per base.html’s expectations at services/canopy-web/templates/base.html):
#[derive(Template)]
#[template(path = "dashboard/worker.html")]
struct WorkerDashboardTemplate {
panels: Vec<RenderedPanel>, // ordered by composition row + slot; each .html is pre-rendered HTML
branding: BrandingConfig, // base.html reads `branding.agency_short` etc.
is_sidebar: bool, // base.html sidebar layout toggle
active_nav: String, // "dashboard" — flags the nav item
worker_name: String,
worker_role: String,
}
Empty composition (zero items after merge) renders a single error_block ("Dashboard composition has no panels — contact your administrator."). Shouldn’t happen in practice; defaults always ship 12 items.
Decision 8 (revised at implementation time): System defaults stay empty; Georgia baseline carries the 12 items
Deviation from earlier plan iterations: the originally-stated symmetry between crates/canopy-composition/defaults/worker_dashboard.json and the Georgia baseline TOML was not achievable. Populating defaults/worker_dashboard.json with 12 items broke canopy-composition’s tests/loader_test.rs (7 tests) because they construct fresh CompositionLoader instances against empty TestPluginSource collections — the 12 worker-dashboard plugins are linkme-registered in canopy-web, not in canopy-composition’s test binary. Populating defaults would force every canopy-composition test to inject 12 dummy plugin definitions.
Resolved by keeping defaults/worker_dashboard.json empty (matches the other 4 surfaces) and putting all 12 items in rulesets/georgia/composition/worker_dashboard.toml alone. Per ADR-022 RFC 7396 array replacement semantics, the baseline items overlay wins → Georgia gets the 12 panels regardless. New jurisdictions adding their own baseline TOML pick up no panels until they declare them — which is correct: each jurisdiction must own its composition explicitly.
defaults.rs keeps a single defaults_ship_empty_items_jurisdiction_baselines_override test covering all 5 surfaces' empty-items invariant. The plan’s earlier stage5_worker_dashboard_defaults_have_12_panels sibling test was not added.
Test changes in crates/canopy-composition/src/defaults.rs:
-
mr1_defaults_ship_empty_items(lines 78-94) — replaced (NOT deleted) with two sibling tests:-
stage5_worker_dashboard_defaults_have_12_panels—WorkerDashboarddefaults haveitems.len() == 12, first slug isworker-dashboard-at-a-glance-panelwith span 12, spans sum per row matches Decision 2. -
non_worker_surfaces_remain_empty_until_their_stage—SupervisorDashboard,AnalystDashboard,CaseDetail,SignIndefaults still haveitems.is_empty().
-
-
every_surface_has_defaultsandcase_detail_default_shell_is_tabs_for_georgia_compatandsign_in_shell_is_empty_string— unchanged.
Decision 9: WorkerRole → RoleSlug mapping + AuthenticatedWorker access pattern
AuthenticatedWorker(pub SessionData) is a tuple struct — services/canopy-web/src/session.rs:259. Field access is worker.0.role, NOT worker.role. Handler uses AuthenticatedWorker(session) destructure pattern.
WorkerRole variants (session.rs:18-24): Caseworker, EligibilitySpecialist, Supervisor, QualityControl, Admin.
idp.toml roles (rulesets/georgia/idp.toml): eligibility_worker, supervisor, jurisdiction_admin, qc.
Mapping (lives at services/canopy-web/src/dashboard/role_map.rs):
fn role_slug_for_worker(role: &WorkerRole) -> RoleSlug {
match role {
WorkerRole::Caseworker | WorkerRole::EligibilitySpecialist =>
RoleSlug("eligibility_worker".to_string()),
WorkerRole::Supervisor =>
RoleSlug("supervisor".to_string()),
WorkerRole::QualityControl =>
RoleSlug("qc".to_string()),
WorkerRole::Admin =>
RoleSlug("jurisdiction_admin".to_string()),
}
}
RoleSlug is pub struct RoleSlug(pub String) (crates/canopy-composition/src/types.rs:85) — no ::new constructor; wrap the string directly.
Caseworker + EligibilitySpecialist intentionally collapse to the same slug — Keycloak’s eligibility_specialist claim widens the realm role beyond caseworker but their composition view is the same in v1. Stage 5 #496 (supervisor + analyst dashboards) introduces role-layer overrides that distinguish supervisor and analyst (the eventual EligibilitySpecialist slug); QC + admin overrides land alongside per design.
In v1 ALL 4 idp.toml roles (eligibility_worker, supervisor, qc, jurisdiction_admin) see the same composition because every panel manifest lists all 4 in required_roles (Decision 11). The role-filter (crates/canopy-composition/src/role_filter.rs:22) silently drops items whose plugin permission excludes the request role, so under-permissioning a panel would produce a partial-dashboard render — Decision 11’s permissions list is deliberately broad to avoid that.
user_id derivation: SessionData.worker_id: String is the Keycloak sub claim. If it parses as a UUID, wrap in Some(UserId::from(uuid)); otherwise None (logged WARN once). The composition loader handles None correctly (skips the user-layer merge).
jurisdiction: source from Extension<Arc<WebConfig>> — web_config.jurisdiction: String. canopy-web is single-tenant per ADR-005 v1; multi-jurisdiction = #516.
Decision 10: CSP and asset discipline
No new inline style= attributes. No new inline <script> blocks. All per-panel styling uses existing Orchard tokens + utility classes. New CSS additions land in services/canopy-web/static/css/canopy-web.css per existing convention.
Panel partials use {% call o::panel_frame(…) %}{% endcall %} block form (askama 0.15 requires {% endcall %}; the askama-015 quirks memory captures this for future reference). Convention for these panels: precompute strings in the fetcher; do not invoke methods inside panel templates. (Note this is a local convention — askama does support method calls and current dashboard.html:55 uses work_queue.is_empty(). The precompute discipline keeps panel partials shape-pure and helps tests assert against handler-side data.)
Decision 11: Exemplar Plugin.toml manifest
Every panel’s Plugin.toml follows this exact shape. (Field order matters for serde-toml round-trip but parse is order-insensitive — the example below is the recommended order.)
[plugin]
slug = "worker-dashboard-at-a-glance"
name = "Worker Dashboard — At-a-Glance"
version = "1.0.0"
author = "canopy-core"
license = "AGPL-3.0-or-later"
canopy_min = "0.1.0"
[plugin.exports]
panels = ["worker-dashboard-at-a-glance-panel"]
case_sections = []
[panels.worker-dashboard-at-a-glance-panel]
display_name_key = "panels.worker_dashboard.at_a_glance.title"
icon = "leaf"
programs = ["snap", "tanf", "medicaid", "caps", "wic"]
default_span = 12
allowed_spans = [12]
required_states = ["empty", "error", "populated"]
[data]
source = "canopy-web" # aggregator panels declare "canopy-web" as self-source
auth = "service_class"
cache_ttl_seconds = 30
timeout_ms = 5000
endpoints = [
"/v1/applications?limit=0",
"/v1/renewals/snap/due?days=30",
"/v1/appeals/queue",
"/v1/renewals/snap/interim-contacts/due",
]
[permissions]
required_roles = ["eligibility_worker", "supervisor", "qc", "jurisdiction_admin"]
audit = "read"
[i18n]
default = "en"
catalogs = ["en"]
Panels whose data isn’t yet wired (FU-1, FU-2, FU-3, FU-4, FU-5, FU-6) declare their future endpoint as a placeholder. The fetcher constructs the panel Template with state = "empty" and never hits the network; updating the fetcher to a real call when the FU lands is the only change needed (manifest unchanged).
Per-panel deviations from the exemplar (the only fields that vary):
-
[plugin]—slug,name -
[plugin.exports].panels— single-entry array with the per-panel export slug -
[panels.<slug>]— entire block (export slug as table key +display_name_key/icon/programs/default_span/allowed_spansper panel) -
[data]—source(which canopy service orcanopy-webfor aggregators),endpoints(per Decision 3 table) -
[permissions].required_roles—["eligibility_worker", "supervisor", "qc", "jurisdiction_admin"]for v1. All 4 roles inrulesets/georgia/idp.tomlsee the same worker dashboard in v1. Role-specific panel curation (= supervisor superset / analyst subset) is Stage 5 #496 via the role layer override (RFC 6902add/removeops on the role-layer composition document). Excluding a role here would cause the loader’s role-filter (role_filter.rs:22) to silently drop the panel for that role and produce a partial-dashboard render — actively worse than serving the same dashboard to all 4 roles in v1.
Steps
(Each "step" below is a logical chunk; the MR ships as one branch.)
Step 1 — Plugin module scaffolding
-
Create directory
services/canopy-web/src/dashboard/andservices/canopy-web/src/dashboard/panels/. -
services/canopy-web/src/dashboard/mod.rs— declarespub mod panels; pub mod role_map; pub mod util;. -
services/canopy-web/src/dashboard/panels/mod.rs— definesRenderedPanel(slug + row + span + html),dispatch_fetch,finalizetemplate-render helper,pub mod at_a_glance; … pub mod unknown_panel;(12 real panels + 1 unknown_panel fallback). NoPanelData/PanelStatetypes; each panel owns its own#[derive(Template)]struct per Decision 6. -
unknown_panel module + template (
services/canopy-web/src/dashboard/panels/unknown_panel.rs+services/canopy-web/templates/dashboard/panels/unknown_panel.html):-
render_unknown_html(item: &ComposedItem, slug: &str) → String— renders an error_block panel for "plugin not registered" cases (composition references a slug not inCANOPY_PLUGINS). -
render_error_html(slug: &str, msg: &str) → String— renders an error_block panel for template-render failures (called fromfinalize). -
render_dashboard_empty() → String— renders a full-page diagnostic (empty composition guard from Decision 7). -
Internally uses an
UnknownPanelTemplate { slug, state: "error", error_text, label: "Panel unavailable" }struct.
-
-
services/canopy-web/src/dashboard/role_map.rs—role_slug_for_worker(WorkerRole) → RoleSlugper Decision 9. + 1 unit test per WorkerRole variant. -
services/canopy-web/src/dashboard/util.rs— NEW. Extracted from currentdashboard.rs:-
format_time_ago(currentlydashboard.rs:410-428) — used by at-a-glance + recent-notices + audit-events partials. -
bucket_appeals_by_program(dashboard.rs:395-407) — kept for future supervisor dashboard. -
timed()helper (dashboard.rs:20-30) — promoted topub(crate)so per-panel fetchers can wrap upstream calls with the same instrumentation pattern (#436diagnostic). Today it’s private insideapi/dashboard.rs. Existing unit tests indashboard.rs:430-454(thebucket_appeals_by_program_*tests) move with the helpers.
-
-
For each of the 12 panels: create
panels/{slug_underscored}.rswithpub struct {PanelName}Plugin;, the[canopy_plugin]attribute per Decision 4, apub struct {Name}PanelTemplate(with[derive(Template)]+#[template(path = "dashboard/panels/{slug_underscored}.html")]), and anasync fn fetch(clients, session, composed_item) → RenderedPanelbody per Decision 5. Wire fetchers per Decision 3’s data-source table — real fetchers for the "Yes" panels; the FU-1/FU-2/FU-3/FU-4/FU-5/FU-6 panels build the panel Template withstate = "empty"and don’t hit the network. -
For each panel: create
panels/{slug_underscored}/Plugin.tomlper Decision 11’s exemplar with per-panel deviations. -
services/canopy-web/src/lib.rs— addpub mod dashboard;if missing (canopy-web restructured to lib+bin during Stage 3 MR2; lib.rs is the right home).
Step 2 — Per-panel templates
-
Create directory
services/canopy-web/templates/dashboard/panels/. -
For each of the 12 panels: create
panels/{slug_underscored}.htmlper Decision 6’s contract. Each declares a per-panel context struct (lives next to thefetchin the matching .rs file: e.g.pub struct AtAGlancePanelView { pending_applications: u64, renewals_due: u64, appeals_pending: u64, interim_contacts_due: u64 }). -
Per-panel
populatedrender uses Stage 1 primitives (big_number,money_cell,status_pill,gold_rule) — no new CSS unless absolutely necessary.
Step 3 — New worker.html + handler rewrite + main.rs extension wiring
-
main.rs rewiring (
services/canopy-web/src/main.rs):-
Move
.layer(axum::Extension(composition_state.clone()))from the composition_router (line 182) to also apply at outer-router scope (alongside the layers at lines 230-244). The sub-router layer stays in place for safety; layers stack idempotently. -
Add
.layer(axum::Extension(boot.db.clone()))at outer-router scope (DbPoolisClone— seecrates/canopy-db). -
WebConfigis notClonetoday (services/canopy-web/src/config.rs:8has#[derive(Debug, Deserialize)]). Two options — pick (a): (a) Arc-wrapsvc_configbefore its existing consumers and substituteArc<WebConfig>for every&WebConfigconsumer below; (b) deriveCloneonWebConfig+ every nestedIdpConfig/Configit contains. *Plan picks (a):let svc_config = Arc::new(svc_config);immediately afterWebConfig::load(), then existing consumers take&*svc_configor accept&WebConfigfrom the Arc deref. Then.layer(axum::Extension(svc_config.clone()))at outer-router scope.
-
-
Cargo.toml additions:
-
Add
canopy-plugin-macros = { path = "crates/canopy-plugin-macros" }to the rootCargo.toml[workspace.dependencies]section (linkmeandfuturesare already there;canopy-plugin-macrosis a workspace member but missing from[workspace.dependencies]). -
Then add to
services/canopy-web/Cargo.toml:canopy-plugin-macros = { workspace = true } linkme = { workspace = true } futures = { workspace = true }-
canopy-plugin-macros— the#[canopy_plugin]attribute macro. -
linkme— the macro expands to::linkme::distributed_sliceso canopy-web’s binary needslinkmesymbols resolvable at link time. -
futures—futures::future::join_allis the fan-out primitive in Decision 7.
-
-
-
Create
services/canopy-web/templates/dashboard/worker.html— extendsbase.html. ReadsWorkerDashboardTemplateper Decision 7. Sets the title + worker_name + page_title + topbar_content blocks; the grid lives in bothcontent(sidebar mode) andtopbar_content(topbar mode) since base.html renders one or the other peris_sidebar(base.html:16vs:81). Pull the grid into a shared{% macro %}to avoid duplication.{% extends "base.html" %} {% block title %}Dashboard{% endblock %} {% block worker_name %}{{ worker_name }}{% endblock %} {% block worker_role %}{{ worker_role }}{% endblock %} {% block page_title %}Dashboard{% endblock %} {% block breadcrumb %}{% endblock %} {% macro panel_grid() %} <div class="worker-dashboard-grid"> {% for panel in panels %} <div class="worker-dashboard-cell" data-panel-slug="{{ panel.slug }}" data-row="{{ panel.row }}" data-span="{{ panel.span }}"> {{ panel.html|safe }} </div> {% endfor %} </div> {% endmacro %} {% block content %}{% call panel_grid() %}{% endcall %}{% endblock %} {% block topbar_content %}{% call panel_grid() %}{% endcall %}{% endblock %}This entirely avoids the
{% include %}parent-context problem from review #3: each panel’s HTML is already rendered (in Rust, against its own Template struct) before worker.html runs. No{% match %}, no 12-arm if-chain, no sharedpanelcontext. The dual-block emission handles both sidebar and topbar layouts since base.html renders only one branch based onis_sidebar. -
Add
WorkerDashboardTemplatestruct inservices/canopy-web/src/api/dashboard.rsper the field list specified in Decision 7 (above). Fields:panels: Vec<RenderedPanel>,branding,is_sidebar,active_nav,worker_name,worker_role. -
Rewrite
services/canopy-web/src/api/dashboard.rs::get_dashboardper Decision 7’s flow. Existing route registration atservices/canopy-web/src/api/mod.rs:30(.route("/", get(dashboard::get_dashboard))) is unchanged — only handler body changes. -
Delete
services/canopy-web/templates/dashboard.html(replaced bydashboard/worker.html). -
Delete the existing
DashboardTemplatestruct + its hand-rolled data fetch blocks (lines 67-377 of currentdashboard.rs). -
The extracted helpers (
format_time_ago,bucket_appeals_by_program) now live indashboard/util.rsand are imported from there.
Step 4 — Composition layer wiring
-
Update
crates/canopy-composition/defaults/worker_dashboard.jsonto populate the 12 items per Decision 2 (every item declaresrowperComposedItem.rowrequirement):{ "shell": "grid", "items": [ {"item": "worker-dashboard-at-a-glance-panel", "row": 0, "span": 12}, {"item": "worker-dashboard-my-queue-panel", "row": 1, "span": 6}, {"item": "worker-dashboard-upcoming-appointments-panel", "row": 1, "span": 6}, {"item": "worker-dashboard-overdue-cases-panel", "row": 2, "span": 6}, {"item": "worker-dashboard-pending-verifications-panel", "row": 2, "span": 6}, {"item": "worker-dashboard-recent-applications-panel", "row": 3, "span": 6}, {"item": "worker-dashboard-recent-determinations-panel", "row": 3, "span": 6}, {"item": "worker-dashboard-recent-notices-panel", "row": 4, "span": 4}, {"item": "worker-dashboard-ievs-alerts-panel", "row": 4, "span": 4}, {"item": "worker-dashboard-cross-program-alerts-panel", "row": 4, "span": 4}, {"item": "worker-dashboard-audit-events-panel", "row": 5, "span": 6}, {"item": "worker-dashboard-system-messages-panel", "row": 5, "span": 6} ] } -
Replace
mr1_defaults_ship_empty_itemstest (defaults.rs:78-94) per Decision 8 with the two sibling tests. Other tests unchanged. -
Populate
rulesets/georgia/composition/worker_dashboard.tomlidentically (TOML form, same 12 items each with row + span):shell = "grid" [[items]] item = "worker-dashboard-at-a-glance-panel" row = 0 span = 12 [[items]] item = "worker-dashboard-my-queue-panel" row = 1 span = 6 # ... 10 more — same row/span per Decision 2
Step 5 — E2E spec audit + per-test action
tests/e2e/specs/dashboard.spec.ts currently has 9 tests. Each gets one of {stay / rewrite / delete}. Test names below are the exact test(…) titles.
| Existing test name | Action | Reason |
|---|---|---|
|
Stay |
Asserts |
|
Rewrite |
|
|
Rewrite |
|
|
Rewrite |
Scope |
|
Stay |
Sidebar nav lives in |
|
Rewrite |
The activity feed is replaced by the |
|
Stay |
Smoke test that page loads + no "Internal Server Error"; unaffected by layout. Still passes against the new dashboard. |
|
Delete |
Per-program cards aren’t in the 12-panel kit. FU-8 files the Studio-customization plugin if a jurisdiction wants per-program rollups back. Case-search filter coverage lives in case-search specs. |
|
Stay |
Tests a CASE DETAIL page ( |
-
Add one new test in the same file:
worker dashboard renders 12 panels in expected order—page.goto('/')+ verify all 12[data-panel-slug]markers exist in document order matching Decision 2’s table, with the expecteddata-rowanddata-spanattrs. -
tests/e2e/specs/screenshots.spec.ts:38dashboardcapture re-baselines (visual diff expected on first run). Update theawait page.waitForSelector(".card")line toawait page.waitForSelector("[data-panel-slug]")— composition-driven dashboard emits panel-frame sections under[data-panel-slug]wrappers, not.card. -
Dark-theme + accessibility specs re-run after selector updates — zero regressions expected.
Step 6 — Tests
-
Per-panel render tests: 12 panels × 3 states = 36 cases at
services/canopy-web/tests/dashboard_panels_test.rs. Construct each panel’sTemplatestruct with eachstatevalue ("populated"/"empty"/"error") and the appropriate field set, render viaaskama::Template::render, assert key class/attr presence. -
Composition→handler integration test at
services/canopy-web/tests/dashboard_composition_test.rs. Pattern mirrorstests/composition_api_test.rs:EphemeralSchema::new_for_web(&db_url())for per-test isolation. Uses the real CompileTimePluginSource — the 12 plugins are linkme-registered at compile time so this test pool sees them. Constructs realServiceClients(concrete struct atservices/canopy-web/src/clients.rs:237— no MockServiceClients type exists, do not invent one) pointing athttpmock::MockServerinstances seeded with canned upstream responses keyed by URL. Addhttpmock = "0.7"toservices/canopy-web/Cargo.toml[dev-dependencies]—httpmockis not currently a workspace dep. Callsget_dashboarddirectly via axum’soneshotor via handler-invocation pattern. Asserts the rendered HTML contains all 12[data-panel-slug]markers in Decision 2’s order with the rightdata-rowanddata-spanattrs. -
Defaults integrity tests (in
defaults.rsper Decision 8). 2 new tests; existing tests preserved. -
role_map tests (in
role_map.rs). 1 test per WorkerRole variant verifying the expected RoleSlug value.
Step 7 — Documentation + CHANGELOG
-
CHANGELOG.adoc— one=== Changedentry under the unreleased section: "Composition-driven worker dashboard (12-panel kit). First canopy-web surface to consume the Stage-3 composition runtime. Refs #495." -
Parent plan (
docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc) — Status table row for Stage 5: change "Stage 5 | #495-#498 ready | Worker dashboard rewrite…" line (master plan Steps section line 300 area) to~~5 MR1 #495~~ | Done (2026-05-22) — !NNN | Worker dashboard composition-driven, 12-panel kit, …per thefeedback_plans_durable_in_repo.mdStatus vocabulary. -
docs/modules/ROOT/pages/services/canopy-web.adoc— Routes section: note thatGET /is now composition-driven. One-line addition under the existing route table. -
.claude/docs/architecture.md— Tier 3 doc. Worker portal subsection: add bullet "12-panel worker dashboard is the first composition-driven canopy-web surface (Stage 5 #495; see ADR-021 + ADR-022)." -
.claude/docs/coding-conventions.md— Tier 2 doc,<!-- PROJECT: worker-portal-patterns -→block. Add one paragraph documenting the "built-in plugins live at `services/canopy-web/src/dashboard/panels/{slug_underscored}/`" pattern + the macro path resolution gotcha (Decision 4).
Step 8 — Follow-up issues filed before commit
Per feedback_no_deferral_accountability — file these BEFORE commit; reason is honest ("dashboard ships ahead of these upstreams; panel surfaces ship today with empty-state until landed"):
-
FU-1: canopy-verification — add
GET /v1/verifications?status=pending&worker_id={user_id}(powerspending-verificationspanel). Status: Done (2026-05-26; canopy-verification’s first domain DB ships withverificationstable + producer write path from the eligibility orchestrator; #519). -
FU-2: canopy-renewals — add cross-program
GET /v1/renewals/overdueaggregator across {snap, tanf, medicaid, caps, wic} (extendsoverdue-casespanel beyond SNAP). Status: Done (2026-05-25, Phase 1 SNAP-only — wire shape carriesprogramso TANF/Medicaid/CAPS/WIC join without manifest change once those services expose per-program due-date endpoints; #520). -
FU-3: canopy-wic — add
GET /v1/wic/appointments/upcoming?days={n}list endpoint (canopy-wic has only POST-create today). Matches the placeholder URL declared in the panel manifest so when this FU lands, the panel fetcher updates without changing the Plugin.toml. Status: Done (2026-05-25; #521). -
FU-4: canopy-verification — add
GET /v1/verifications/ievs/discrepancies?limit={n}(powersievs-alertspanel). Status: Done (2026-05-26;ievs_hitstable + adapter-callback persistence inapi/ievs.rs::handle_ievs_match; #522). -
FU-5: canopy-eligibility — add
GET /v1/eligibility/cross-program-alerts?worker_id={user_id}(powerscross-program-alertspanel). Status: Done (2026-05-25, Phase 1 derives alerts fromprogram_determinationsrows;worker_idaccepted but Phase 2 will gate viacanopy-applications.household_assignments; #523). -
FU-6: design + canopy-web — Jurisdiction-broadcast system-messages surface (powers
system-messagespanel; design Q on what canopy supports for jurisdiction-wide announcements). -
FU-7: canopy-web — Worker dashboard panel refresh affordance + loading-state render path. Adds
required_states += "loading"per panel; htmx-driven per-panel refresh; live polling for time-sensitive panels. -
FU-8: design — Per-program rollup cards as a Studio-customization plugin (was the old
#393test target). Jurisdictions that want per-program counts wire as aprogram-rollup-{snap|tanf|…}plugin in their composition baseline. -
FU-9: canopy-web
InternalClient— wire per-call timeout fromPlugin.toml::data.timeout_ms(currently hardcoded 5s atclients.rs:37). Manifest field is declarative in v1; wiring lets jurisdictions tune per-panel under load. -
FU-10: canopy-web — full 36-test per-panel state matrix (\#528). MR1 ships state tests for 4 of 12 panels (at_a_glance, my_queue, pending_verifications, unknown_panel — ~10 tests). FU-10 fills out the remaining 8 panels' 3-state coverage.
Files touched
| Path | Change |
|---|---|
|
NEW |
|
NEW (+ 5 unit tests) |
|
NEW (extracted helpers + their tests) |
|
NEW ( |
|
NEW (×12) |
|
NEW (error/unknown/empty-composition fallback rendering) |
|
NEW (×12) |
|
NEW |
|
NEW (×12) |
|
NEW |
|
REWRITE handler body; introduce |
|
unchanged (route stable) |
|
MODIFIED (3 new outer-router Extension layers + |
|
MODIFIED (add |
|
MODIFIED (deps: add |
|
add |
|
MODIFIED (panel grid + minor typography only) |
|
DELETE (replaced by worker.html) |
|
MODIFIED (12 items) |
|
MODIFIED (1 test replaced by 2 siblings) |
|
MODIFIED (12 items) |
|
NEW (36 cases) |
|
NEW (1-2 integration cases) |
|
MODIFIED per Step 5 action table |
|
MODIFIED (re-baseline) |
|
MODIFIED (one entry) |
|
MODIFIED (Status row) |
|
NEW (this plan) |
|
MODIFIED (Routes note) |
|
MODIFIED (worker portal bullet) |
|
MODIFIED (panels pattern paragraph) |
Rough count: ~50 files modified or added.
Verification
-
cargo xtask validateclean (fmt + clippy + nextest + check-docs) -
cargo xtask docs plan-lintclean -
Pre-push validate Playwright suite passes (≥ 142 currently green); +1 new test from Step 5
-
axe-core WCAG 2.1 AA clean across light + dark themes
-
Per-panel state assertions (36 cases) green
-
Composition→handler→render integration test green
-
MR pipeline: skip CI per project convention; pre-push validate is the trusted gate per
feedback_skip_ci
Pre-commit Q1-Q8 expectations
-
Q1 — Per-panel state assertions, composition integration test, role_map unit tests, E2E selector + new-test updates; all 12 panels exercised through all 3 states.
-
Q2 — No
unwrapoutside tests, nounsafe, no#[allow]. -
Q3 —
mr1_defaults_ship_empty_itemsis replaced by two more-specific tests (not net-deleted). No other test deletions or weakened assertions. dashboard.spec.ts changes per Step 5 are rewrites that preserve test intent (worker authenticates → dashboard renders → can navigate); not weakenings. -
Q4 — Both open Qs on #495 resolved in Decision 2. Mid-build deviations update this plan’s Design section + file design-iteration issues if material.
-
Q5 — Stage-5 MR1 does NOT close #460; only the final Stage-7 MR does.
-
Q6 — Out-of-scope deferred: supervisor dashboards (= #496), customize-my-dashboard (= #498), case-detail (= #497), htmx refresh polling + loading-state render path (= FU-7), real upstream endpoints for FU-1/FU-2/FU-3/FU-4/FU-5/FU-6 (six placeholder panels), per-program rollup cards (= FU-8), per-call timeout wiring (= FU-9).
-
Q7 — CHANGELOG entry + Status row + canopy-web service doc + architecture.md bullet + coding-conventions.md paragraph (per Step 7).
-
Q8 — Zero new TODO/FIXME tokens. FU-1..FU-10 filed as GitLab issues, not as in-code TODOs.
Risks + Rollback
| Risk | Trigger | Mitigation |
|---|---|---|
12-panel data fan-out slow under devstack |
upstream services serial under network contention |
|
linkme distributed slice not seeing test plugins |
Stage 3 ships empty slice; tests need real plugin metadata |
This MR DOES register all 12 plugins at compile time — the slice is populated for the test pool. Test asserts |
dashboard.spec.ts breaks more than expected |
a selector this plan didn’t catalog |
Per-test action table in Step 5 enumerates all known existing tests + actions; pre-push validate runs full Playwright suite to surface anything missed. |
Manifest validation rejects a panel manifest |
invalid spans / empty endpoints / regex / semver |
Decision 11 specifies the exemplar; manifest.rs validates at compile-time-test boundary. Pre-push validate would surface a bad manifest. |
Mock upstream response shapes don’t match production |
test fixture drift |
Composition integration test pins request URLs and response JSON shapes; if a real upstream contract drifts, the test catches it before production |
FU-1..FU-10 panels render as empty states too long |
follow-ups remain open |
All filed as |
Rollback: revert the MR. The composition runtime + Stage 3 layers remain intact. dashboard.rs reverts to its current monolithic form. No DB schema changes in this MR (composition schema landed in Stage 3 MR1). The mr1_defaults_ship_empty_items partial replacement reverts cleanly.
Open questions resolved by this plan
-
Default panel ordering (open Q on #495) — resolved per Decision 2.
-
Panel span defaults (open Q on #495) — resolved per Decision 2;
default_spanplusallowed_spansdocumented per panel.
No open questions remain for Stage 5 MR1. (Stage 5 MR2/MR3 = #496/#498 inherit decisions here; Stage 5 MR4 = #497 has its own plan.)
Related work
-
Parent plan — Stage 5 listed in master plan’s Status table.
-
Stage 3 MR1 plan — composition runtime this MR consumes.
-
Stage 3 MR2 plan — override APIs that target this surface from #498.
-
Stage 4 plan — sibling stage shipping identity surface.
-
ADR-021 — plugin manifest schema this MR’s 12 manifests conform to.
-
ADR-022 — 5-layer merge semantics + RFC 6902/7396.