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 itemsmr1_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

  1. 12 panel plugins registered via #[canopy_plugin] (panel slugs listed in Decision 1).

  2. Per-panel Plugin.toml manifest + Askama partial template + Rust data fetcher.

  3. New worker dashboard template at services/canopy-web/templates/dashboard/worker.html that consumes ComposedSurface and renders panels per the composition loader’s ordering + spans.

  4. New dashboard handler that calls composition_loader.load_composition(WorkerDashboard, jurisdiction, role, user_id, idp), fans out per-panel fetchers in parallel, and renders worker.html. Replaces the existing get_dashboard handler at the GET / route — URL preserved.

  5. Populated crates/canopy-composition/defaults/worker_dashboard.json (12 items with default spans) — replaces the empty fixture. Per-surface empty_items invariant retained for the other 4 surfaces.

  6. 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).

  7. Updated tests/e2e/specs/dashboard.spec.ts (test-by-test plan in Step 5; some tests stay, some rewrite, some delete).

  8. 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_states declaration (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)

worker-dashboard-at-a-glance

worker-dashboard-at-a-glance-panel

worker-dashboard-my-queue

worker-dashboard-my-queue-panel

worker-dashboard-recent-applications

worker-dashboard-recent-applications-panel

worker-dashboard-pending-verifications

worker-dashboard-pending-verifications-panel

worker-dashboard-overdue-cases

worker-dashboard-overdue-cases-panel

worker-dashboard-upcoming-appointments

worker-dashboard-upcoming-appointments-panel

worker-dashboard-recent-determinations

worker-dashboard-recent-determinations-panel

worker-dashboard-recent-notices

worker-dashboard-recent-notices-panel

worker-dashboard-ievs-alerts

worker-dashboard-ievs-alerts-panel

worker-dashboard-cross-program-alerts

worker-dashboard-cross-program-alerts-panel

worker-dashboard-audit-events

worker-dashboard-audit-events-panel

worker-dashboard-system-messages

worker-dashboard-system-messages-panel

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:122pub 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

worker-dashboard-at-a-glance-panel

0

12

Hero row

2

worker-dashboard-my-queue-panel

1

6

Row 1 left (6+6=12)

3

worker-dashboard-upcoming-appointments-panel

1

6

Row 1 right

4

worker-dashboard-overdue-cases-panel

2

6

Row 2 left (6+6=12)

5

worker-dashboard-pending-verifications-panel

2

6

Row 2 right

6

worker-dashboard-recent-applications-panel

3

6

Row 3 left (6+6=12)

7

worker-dashboard-recent-determinations-panel

3

6

Row 3 right

8

worker-dashboard-recent-notices-panel

4

4

Row 4 (4+4+4=12)

9

worker-dashboard-ievs-alerts-panel

4

4

Row 4

10

worker-dashboard-cross-program-alerts-panel

4

4

Row 4

11

worker-dashboard-audit-events-panel

5

6

Row 5 left (6+6=12)

12

worker-dashboard-system-messages-panel

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?

at-a-glance

(aggregator)

4 existing endpoints already in current dashboard.rs:109-150

Yes

my-queue

(aggregator)

3 existing endpoints (apps + renewals + appeals) per current dashboard.rs:220-321

Yes

recent-applications

canopy-applications

GET /v1/applications?limit=10

Yes

pending-verifications

canopy-verification

placeholder: GET /v1/verifications?status=pending&worker_id={user_id}

No — FU-1

overdue-cases

canopy-renewals

placeholder: GET /v1/renewals/overdue (cross-program list endpoint doesn’t exist; existing /v1/renewals/snap/due is a due-soon, not overdue, list)

No — FU-2

upcoming-appointments

canopy-wic

placeholder: GET /v1/wic/appointments/upcoming?days=7 (canopy-wic exposes only POST-create today; no list endpoint). FU-3 builds exactly this URL.

No — FU-3

recent-determinations

(aggregator)

5 program services' GET /v1/determinations?limit=2

Yes

recent-notices

canopy-notices

GET /v1/notices?limit=10

Yes

ievs-alerts

canopy-verification

placeholder: GET /v1/verifications/ievs/discrepancies?limit=10

No — FU-4

cross-program-alerts

canopy-eligibility

placeholder: GET /v1/eligibility/cross-program-alerts?worker_id={user_id}

No — FU-5

audit-events

canopy-security

GET /v1/security/events?limit=10

Yes

system-messages

canopy-web (self)

placeholder: GET /v1/system-messages?worker_id={user_id}

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:

  1. Hit upstream endpoint(s) (or skip for FU placeholders) → Result<view, Err>.

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

  3. Set state = "populated" | "empty" | "error" per outcome (see Decision 6 — &'static str, NOT enum).

  4. Precompute count_text: String from the count, error_text: String from 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 current dashboard.html:55) — but precomputing keeps panel partials shape-pure and helps tests assert against handler-side data.

  5. Call .render() (returns Result<String, askama::Error>) and wrap into RenderedPanel. Use a helper to convert a render-failure into an error-state panel rather than unwrap/? 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). No unwrap outside tests (Q2 hard rule); errors degrade gracefully to the unknown_panel error fallback (Decision 7 / Step 1 file panels/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) — note count is 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 to composition_router only (main.rs:182). Move the .layer(axum::Extension(composition_state.clone())) to the outer router (alongside idp_runtime/service_clients/theme_config/etc layers at main.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::DbPool is the typed wrapper. Add .layer(axum::Extension(boot.db.clone())) to the outer router. Handler calls .inner() to get the &PgPool the loader needs.

  • Extension<Arc<WebConfig>> — today svc_config: WebConfig is 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_panelsWorkerDashboard defaults have items.len() == 12, first slug is worker-dashboard-at-a-glance-panel with span 12, spans sum per row matches Decision 2.

    • non_worker_surfaces_remain_empty_until_their_stageSupervisorDashboard, AnalystDashboard, CaseDetail, SignIn defaults still have items.is_empty().

  • every_surface_has_defaults and case_detail_default_shell_is_tabs_for_georgia_compat and sign_in_shell_is_empty_string — unchanged.

Decision 9: WorkerRole → RoleSlug mapping + AuthenticatedWorker access pattern

AuthenticatedWorker(pub SessionData) is a tuple structservices/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_spans per panel)

  • [data]source (which canopy service or canopy-web for aggregators), endpoints (per Decision 3 table)

  • [permissions].required_roles["eligibility_worker", "supervisor", "qc", "jurisdiction_admin"] for v1. All 4 roles in rulesets/georgia/idp.toml see the same worker dashboard in v1. Role-specific panel curation (= supervisor superset / analyst subset) is Stage 5 #496 via the role layer override (RFC 6902 add/remove ops 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

  1. Create directory services/canopy-web/src/dashboard/ and services/canopy-web/src/dashboard/panels/.

  2. services/canopy-web/src/dashboard/mod.rs — declares pub mod panels; pub mod role_map; pub mod util;.

  3. services/canopy-web/src/dashboard/panels/mod.rs — defines RenderedPanel (slug + row + span + html), dispatch_fetch, finalize template-render helper, pub mod at_a_glance; …​ pub mod unknown_panel; (12 real panels + 1 unknown_panel fallback). No PanelData / PanelState types; each panel owns its own #[derive(Template)] struct per Decision 6.

  4. 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 in CANOPY_PLUGINS).

    • render_error_html(slug: &str, msg: &str) → String — renders an error_block panel for template-render failures (called from finalize).

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

  5. services/canopy-web/src/dashboard/role_map.rsrole_slug_for_worker(WorkerRole) → RoleSlug per Decision 9. + 1 unit test per WorkerRole variant.

  6. services/canopy-web/src/dashboard/util.rs — NEW. Extracted from current dashboard.rs:

    • format_time_ago (currently dashboard.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 to pub(crate) so per-panel fetchers can wrap upstream calls with the same instrumentation pattern (#436 diagnostic). Today it’s private inside api/dashboard.rs. Existing unit tests in dashboard.rs:430-454 (the bucket_appeals_by_program_* tests) move with the helpers.

  7. For each of the 12 panels: create panels/{slug_underscored}.rs with pub struct {PanelName}Plugin;, the [canopy_plugin] attribute per Decision 4, a pub struct {Name}PanelTemplate (with [derive(Template)] + #[template(path = "dashboard/panels/{slug_underscored}.html")]), and an async fn fetch(clients, session, composed_item) → RenderedPanel body 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 with state = "empty" and don’t hit the network.

  8. For each panel: create panels/{slug_underscored}/Plugin.toml per Decision 11’s exemplar with per-panel deviations.

  9. services/canopy-web/src/lib.rs — add pub 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

  1. Create directory services/canopy-web/templates/dashboard/panels/.

  2. For each of the 12 panels: create panels/{slug_underscored}.html per Decision 6’s contract. Each declares a per-panel context struct (lives next to the fetch in the matching .rs file: e.g. pub struct AtAGlancePanelView { pending_applications: u64, renewals_due: u64, appeals_pending: u64, interim_contacts_due: u64 }).

  3. Per-panel populated render 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

  1. 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 (DbPool is Clone — see crates/canopy-db).

    • WebConfig is not Clone today (services/canopy-web/src/config.rs:8 has #[derive(Debug, Deserialize)]). Two options — pick (a): (a) Arc-wrap svc_config before its existing consumers and substitute Arc<WebConfig> for every &WebConfig consumer below; (b) derive Clone on WebConfig + every nested IdpConfig / Config it contains. *Plan picks (a): let svc_config = Arc::new(svc_config); immediately after WebConfig::load(), then existing consumers take &*svc_config or accept &WebConfig from the Arc deref. Then .layer(axum::Extension(svc_config.clone())) at outer-router scope.

  2. Cargo.toml additions:

    • Add canopy-plugin-macros = { path = "crates/canopy-plugin-macros" } to the root Cargo.toml [workspace.dependencies] section (linkme and futures are already there; canopy-plugin-macros is 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_slice so canopy-web’s binary needs linkme symbols resolvable at link time.

      • futuresfutures::future::join_all is the fan-out primitive in Decision 7.

  3. Create services/canopy-web/templates/dashboard/worker.html — extends base.html. Reads WorkerDashboardTemplate per Decision 7. Sets the title + worker_name + page_title + topbar_content blocks; the grid lives in both content (sidebar mode) and topbar_content (topbar mode) since base.html renders one or the other per is_sidebar (base.html:16 vs :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 shared panel context. The dual-block emission handles both sidebar and topbar layouts since base.html renders only one branch based on is_sidebar.

  4. Add WorkerDashboardTemplate struct in services/canopy-web/src/api/dashboard.rs per the field list specified in Decision 7 (above). Fields: panels: Vec<RenderedPanel>, branding, is_sidebar, active_nav, worker_name, worker_role.

  5. Rewrite services/canopy-web/src/api/dashboard.rs::get_dashboard per Decision 7’s flow. Existing route registration at services/canopy-web/src/api/mod.rs:30 (.route("/", get(dashboard::get_dashboard))) is unchanged — only handler body changes.

  6. Delete services/canopy-web/templates/dashboard.html (replaced by dashboard/worker.html).

  7. Delete the existing DashboardTemplate struct + its hand-rolled data fetch blocks (lines 67-377 of current dashboard.rs).

  8. The extracted helpers (format_time_ago, bucket_appeals_by_program) now live in dashboard/util.rs and are imported from there.

Step 4 — Composition layer wiring

  1. Update crates/canopy-composition/defaults/worker_dashboard.json to populate the 12 items per Decision 2 (every item declares row per ComposedItem.row requirement):

    {
      "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}
      ]
    }
  2. Replace mr1_defaults_ship_empty_items test (defaults.rs:78-94) per Decision 8 with the two sibling tests. Other tests unchanged.

  3. Populate rulesets/georgia/composition/worker_dashboard.toml identically (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

dashboard loads with page title (line 4)

Stay

Asserts .page-title contains "Dashboard"; survives layout rewrite since {% block page_title %} is set to "Dashboard" in worker.html.

dashboard shows the 4 top stat cards (line 9)

Rewrite

.card filtered by 4 stat labels is replaced by scoping into [data-panel-slug="worker-dashboard-at-a-glance-panel"] and asserting the 4 stat labels appear inside it.

stat card values are numeric (line 27)

Rewrite

.card .u-stat → scope into the at-a-glance panel and use the big_number value rendering (.big-number per orchard.html:9-12).

work queue shows table or empty state (line 39)

Rewrite

Scope .data-table.or(text=all caught up) into [data-panel-slug="worker-dashboard-my-queue-panel"]. The empty-state title text is "All caught up" — getByText works since o::empty_state (orchard.html:94) doesn’t emit a data-* attribute for the title.

sidebar shows navigation links (line 47)

Stay

Sidebar nav lives in base.html; unchanged.

activity feed section exists (line 54)

Rewrite

The activity feed is replaced by the audit-events panel. Rewrite to assert [data-panel-slug="worker-dashboard-audit-events-panel"] is present.

dashboard handles zero stats gracefully (line 62)

Stay

Smoke test that page loads + no "Internal Server Error"; unaffected by layout. Still passes against the new dashboard.

#393: per-program cards deep-link into program-filtered case search (line 71)

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.

#394: ?program=all renders cross-program summary matrix (line 81)

Stay

Tests a CASE DETAIL page (/cases/{id}?program=all), not the dashboard. Despite living in dashboard.spec.ts, unaffected by this MR.

  1. Add one new test in the same file: worker dashboard renders 12 panels in expected orderpage.goto('/') + verify all 12 [data-panel-slug] markers exist in document order matching Decision 2’s table, with the expected data-row and data-span attrs.

  2. tests/e2e/specs/screenshots.spec.ts:38 dashboard capture re-baselines (visual diff expected on first run). Update the await page.waitForSelector(".card") line to await page.waitForSelector("[data-panel-slug]") — composition-driven dashboard emits panel-frame sections under [data-panel-slug] wrappers, not .card.

  3. Dark-theme + accessibility specs re-run after selector updates — zero regressions expected.

Step 6 — Tests

  1. Per-panel render tests: 12 panels × 3 states = 36 cases at services/canopy-web/tests/dashboard_panels_test.rs. Construct each panel’s Template struct with each state value ("populated"/"empty"/"error") and the appropriate field set, render via askama::Template::render, assert key class/attr presence.

  2. Composition→handler integration test at services/canopy-web/tests/dashboard_composition_test.rs. Pattern mirrors tests/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 real ServiceClients (concrete struct at services/canopy-web/src/clients.rs:237no MockServiceClients type exists, do not invent one) pointing at httpmock::MockServer instances seeded with canned upstream responses keyed by URL. Add httpmock = "0.7" to services/canopy-web/Cargo.toml [dev-dependencies]httpmock is not currently a workspace dep. Calls get_dashboard directly via axum’s oneshot or via handler-invocation pattern. Asserts the rendered HTML contains all 12 [data-panel-slug] markers in Decision 2’s order with the right data-row and data-span attrs.

  3. Defaults integrity tests (in defaults.rs per Decision 8). 2 new tests; existing tests preserved.

  4. role_map tests (in role_map.rs). 1 test per WorkerRole variant verifying the expected RoleSlug value.

Step 7 — Documentation + CHANGELOG

  1. CHANGELOG.adoc — one === Changed entry under the unreleased section: "Composition-driven worker dashboard (12-panel kit). First canopy-web surface to consume the Stage-3 composition runtime. Refs #495."

  2. 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 the feedback_plans_durable_in_repo.md Status vocabulary.

  3. docs/modules/ROOT/pages/services/canopy-web.adoc — Routes section: note that GET / is now composition-driven. One-line addition under the existing route table.

  4. .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)."

  5. .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} (powers pending-verifications panel). Status: Done (2026-05-26; canopy-verification’s first domain DB ships with verifications table + producer write path from the eligibility orchestrator; #519).

  • FU-2: canopy-renewals — add cross-program GET /v1/renewals/overdue aggregator across {snap, tanf, medicaid, caps, wic} (extends overdue-cases panel beyond SNAP). Status: Done (2026-05-25, Phase 1 SNAP-only — wire shape carries program so 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} (powers ievs-alerts panel). Status: Done (2026-05-26; ievs_hits table + adapter-callback persistence in api/ievs.rs::handle_ievs_match; #522).

  • FU-5: canopy-eligibility — add GET /v1/eligibility/cross-program-alerts?worker_id={user_id} (powers cross-program-alerts panel). Status: Done (2026-05-25, Phase 1 derives alerts from program_determinations rows; worker_id accepted but Phase 2 will gate via canopy-applications.household_assignments; #523).

  • FU-6: design + canopy-web — Jurisdiction-broadcast system-messages surface (powers system-messages panel; 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 #393 test target). Jurisdictions that want per-program counts wire as a program-rollup-{snap|tanf|…​} plugin in their composition baseline.

  • FU-9: canopy-web InternalClient — wire per-call timeout from Plugin.toml::data.timeout_ms (currently hardcoded 5s at clients.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

services/canopy-web/src/dashboard/mod.rs

NEW

services/canopy-web/src/dashboard/role_map.rs

NEW (+ 5 unit tests)

services/canopy-web/src/dashboard/util.rs

NEW (extracted helpers + their tests)

services/canopy-web/src/dashboard/panels/mod.rs

NEW (RenderedPanel, dispatch_fetch, finalize helper)

services/canopy-web/src/dashboard/panels/{12 slugs}.rs

NEW (×12)

services/canopy-web/src/dashboard/panels/unknown_panel.rs

NEW (error/unknown/empty-composition fallback rendering)

services/canopy-web/src/dashboard/panels/{12 slugs}/Plugin.toml

NEW (×12)

services/canopy-web/templates/dashboard/worker.html

NEW

services/canopy-web/templates/dashboard/panels/{12 slugs}.html

NEW (×12)

services/canopy-web/templates/dashboard/panels/unknown_panel.html

NEW

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

REWRITE handler body; introduce WorkerDashboardTemplate struct

services/canopy-web/src/api/mod.rs

unchanged (route stable)

services/canopy-web/src/main.rs

MODIFIED (3 new outer-router Extension layers + Arc::new(svc_config) rewire — see Step 3)

Cargo.toml (workspace root)

MODIFIED (add canopy-plugin-macros to [workspace.dependencies])

services/canopy-web/Cargo.toml

MODIFIED (deps: add canopy-plugin-macros, linkme, futures; dev-deps: add httpmock = "0.7")

services/canopy-web/src/lib.rs

add pub mod dashboard; if missing

services/canopy-web/static/css/canopy-web.css

MODIFIED (panel grid + minor typography only)

services/canopy-web/templates/dashboard.html

DELETE (replaced by worker.html)

crates/canopy-composition/defaults/worker_dashboard.json

MODIFIED (12 items)

crates/canopy-composition/src/defaults.rs

MODIFIED (1 test replaced by 2 siblings)

rulesets/georgia/composition/worker_dashboard.toml

MODIFIED (12 items)

services/canopy-web/tests/dashboard_panels_test.rs

NEW (36 cases)

services/canopy-web/tests/dashboard_composition_test.rs

NEW (1-2 integration cases)

tests/e2e/specs/dashboard.spec.ts

MODIFIED per Step 5 action table

tests/e2e/specs/screenshots.spec.ts

MODIFIED (re-baseline)

CHANGELOG.adoc

MODIFIED (one entry)

docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc

MODIFIED (Status row)

docs/modules/ROOT/pages/plans/archive/worker-portal-redesign-stage5-worker-dashboard.adoc

NEW (this plan)

docs/modules/ROOT/pages/services/canopy-web.adoc

MODIFIED (Routes note)

.claude/docs/architecture.md

MODIFIED (worker portal bullet)

.claude/docs/coding-conventions.md

MODIFIED (panels pattern paragraph)

Rough count: ~50 files modified or added.

Verification

  • cargo xtask validate clean (fmt + clippy + nextest + check-docs)

  • cargo xtask docs plan-lint clean

  • 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 unwrap outside tests, no unsafe, no #[allow].

  • Q3 — mr1_defaults_ship_empty_items is 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

InternalClient’s hardcoded 5s reqwest timeout (`clients.rs:37) gates per-panel latency; per-panel Plugin.toml::data.timeout_ms is declarative in v1 (real wiring = FU-9). Fetchers convert upstream errors to state = "error" rather than block whole render. The timed() helper (moved to dashboard/util.rs::timed per Step 1) is reused per fetcher to instrument upstream call latency.

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 CANOPY_PLUGINS.len() == 12 at startup.

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 priority::medium so they show up in normal backlog grooming

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_span plus allowed_spans documented 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.)

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

Edit this page · default