Plan: UI Consistency Hardening

On this page

Status

Step Description Status

1

Plan file and nav entry

Done (pre-ADR-030)

2

Askama date formatting filter

Done (pre-ADR-030)

3

Askama enum humanize filter

Done (pre-ADR-030)

4

Apply date + enum filters across all templates

Done (pre-ADR-030)

5

Fix exchange partner detail 404

Done (pre-ADR-030)

6

Consistent status badge mappings

Done (pre-ADR-030)

7

Form section header consistency

Done (pre-ADR-030)

8

New Placement form — replace raw UUID inputs with searchable selects

Deferred (requires investigation of cross-service fetch in BFF context)

9

Dashboard stat cards — wire live counts and remove stale copy

Done (pre-ADR-030)

10

Pagination partial — extract shared template

Deferred (works correctly, just duplicated — extract in separate MR)

11

Seed archive records for documentation screenshots

Done (pre-ADR-030)

12

Regenerate screenshots and update E2E assertions

Done (2026-03-20) — MR !42

13

Documentation updates, issue, commit, push, MR

Done (2026-03-20) — MR !42

Issues: #137
Branch: feature/ui-consistency-hardening

Context

A visual review of all 47 Playwright-captured screenshots (generated 2026-03-19) revealed multiple UI/UX consistency issues across the craig-web BFF. These are purely frontend template and rendering issues — no API or database changes are required.

The issues fall into these categories:

  1. Date formatting — dates displayed as truncated ISO strings (2024-06-15…​) or raw ISO 8601 with sub-second precision (2026-03-19T20:54:36.175264Z). The current approach uses |truncate(10) in some templates and raw {{ field }} in others.

  2. Enum display — API enum values displayed as raw snake_case (foster_care, physical_abuse) instead of human-readable labels ("Foster Care", "Physical Abuse").

  3. Status badges — inconsistent use of badge CSS classes across modules; some statuses fall through to raw text or |capitalize instead of proper badge mapping.

  4. Exchange partner detail 404 — the handler fetches only page 1 (per_page=100) of partners and searches for the ID, failing when the partner isn’t on that page. The exchange API does have a direct GET /v1/exchange/partners/{id} endpoint that should be used instead.

  5. Form section headers — some forms use <h2 class="card__subheading"> (uppercase, dark-green, small font) and some have no section headers at all.

  6. New Placement form — requires raw UUID input for Case ID and Child ID, which is unusable.

  7. Dashboard — stat cards show placeholder em-dashes and stale copy ("Dashboard data will populate as modules are implemented") even though all modules are complete.

  8. Pagination — identical pagination HTML is duplicated across ~15 templates; should be a shared Askama include.

Scope

In scope:

  • Askama custom filter for date formatting (replaces truncate(10) and raw output)

  • Askama custom filter for snake_case-to-Title Case humanization

  • Apply both filters across all ~40 templates that display dates or enum values

  • Fix exchange partner detail to use direct API endpoint

  • Normalize all status badge mappings across every list and detail template

  • Add cardsubheading + carddivider section headers to forms that lack them

  • Replace raw UUID text inputs on new placement form with searchable select dropdowns

  • Wire dashboard stat cards to live API counts

  • Extract pagination HTML into a shared Askama include

  • Seed an archive record so the Security Archive page has data in screenshots

  • Regenerate all 47 screenshots

  • Update affected E2E assertions that match on old formatting

Out of scope:

  • Worker identity / name resolution (UUID → display name) — tracked separately in roadmap under "Reference Data — Remaining Steps"

  • Section 508 / WCAG accessibility — tracked in roadmap Phase 10

  • Multi-language support — tracked in roadmap Phase 10

  • Reporting dashboard — tracked in roadmap Phase 10

Design

Custom Askama Filters

Askama supports custom filters as a module of public functions. The existing craig-web crate already has an Askama integration. We add a filters module with two functions.

format_date(s: &str) → askama::Result<String>

Accepts a date/datetime string in any of these formats:

  • 2026-03-19T20:54:36.175264Z (full ISO 8601)

  • 2026-03-19T20:54:36Z

  • 2026-03-19

Returns: Mar 19, 2026 (month abbreviation, day, four-digit year).

Implementation: parse with chrono::NaiveDateTime::parse_from_str falling back to chrono::NaiveDate::parse_from_str, then format with %b %d, %Y. If parsing fails, return the first 10 characters as fallback (preserves current behavior).

humanize(s: &str) → askama::Result<String>

Converts snake_case enum values to Title Case. Split on _, capitalize each word, join with spaces.

Examples:

  • foster_careFoster Care

  • physical_abusePhysical Abuse

  • pending_reviewPending Review

  • 24_hour24 Hour

  • screened_outScreened Out

Filter Registration

Askama custom filters are registered by placing them in a module and referencing them in templates via {{ value|filter_name }}. Add the filter module at services/craig-web/src/filters.rs and declare it in Askama config or import it in the template module.

Per Askama conventions, add to Cargo.toml:

[package.metadata.askama]

And in each template that uses filters, add use crate::filters; in the template’s Rust struct module. Alternatively, register them globally via askama.toml at the workspace root.

Exchange Partner Detail Fix

The current handler at services/craig-web/src/routes/exchange.rs:624-692 fetches all partners via GET /v1/exchange/partners?page=1&per_page=100 and searches the list. The exchange API already exposes GET /v1/exchange/partners/{id} (confirmed in services.md). Replace the list-and-search pattern with a direct GET call.

Dashboard Live Stats

The dashboard handler must make API calls to fetch counts:

  • Total Cases: GET /v1/cases?page=1&per_page=1 → use total from PageResponse

  • Open Investigations: GET /v1/investigations?status=open&page=1&per_page=1 → use total

  • Active Placements: GET /v1/placements?status=active&page=1&per_page=1 → use total

  • Pending Reviews: GET /v1/security/reviews?status=scheduled&page=1&per_page=1 → use total

Fetch all four in parallel with tokio::join!. Admin sees all; caseworker sees role-filtered results (the API already applies RBAC).

Placement Form — Case/Child Pickers

Replace the raw <input type="text" placeholder="UUID"> fields for Case ID and Child ID with two-step approach:

  1. Case ID: <select> dropdown populated from GET /v1/cases?status=open&per_page=100 showing case numbers.

  2. Child ID: dynamic <select> that populates via htmx when a case is selected — calls GET /v1/cases/{case_id}/persons?role=child to list children in that case’s household.

This pattern already exists in the foster home select on the same form (line 23-29 of new_placement.html).

Pagination Partial

Extract the pagination HTML into templates/_pagination.html. The partial receives these variables (passed via Askama include): page, total_pages, total, base_url, extra_params.

Since Askama include shares the parent template’s scope, the partial can reference these variables directly as long as they’re defined in the parent struct.

Steps

Step 1: Plan file and nav entry

Files: docs/modules/ROOT/pages/plans/ui-consistency-hardening.adoc, docs/modules/ROOT/nav.adoc

Create this plan file and add it under the Planned section of nav.adoc:

*** xref:plans/ui-consistency-hardening.adoc[UI Consistency Hardening]

Step 2: Askama date formatting filter

Files: services/craig-web/Cargo.toml, services/craig-web/src/filters.rs, services/craig-web/src/lib.rs (or main.rs)

  1. Add chrono dependency to craig-web/Cargo.toml (features: [], no serde needed — parsing only):

    chrono = { version = "0.4", default-features = false, features = ["std"] }
  2. Create services/craig-web/src/filters.rs:

    /// Custom Askama filters for craig-web templates.
    
    /// Format an ISO 8601 date or datetime string as "Mar 19, 2026".
    /// Falls back to first 10 characters if parsing fails.
    pub fn format_date(s: &str) -> askama::Result<String> {
        use chrono::{NaiveDate, NaiveDateTime};
    
        // Try full datetime first (with or without fractional seconds)
        if let Ok(dt) = NaiveDateTime::parse_from_str(
            s.trim_end_matches('Z'),
            "%Y-%m-%dT%H:%M:%S%.f",
        ) {
            return Ok(dt.format("%b %d, %Y").to_string());
        }
    
        // Try date-only
        if let Ok(d) = NaiveDate::parse_from_str(&s[..10.min(s.len())], "%Y-%m-%d") {
            return Ok(d.format("%b %d, %Y").to_string());
        }
    
        // Fallback: truncate to 10 chars (preserves old behavior)
        Ok(s.chars().take(10).collect())
    }
  3. Declare the module in main.rs:

    mod filters;
  4. Register filters with Askama. Create or update services/craig-web/askama.toml:

    [general]
    dirs = ["templates"]
    In each template struct's module, ensure filters are accessible.
    Askama 0.12+ supports custom filter modules via the `#[template(ext = "html", path = "...", config = "...")]` attribute or by placing filters in a module named `filters` in the crate root (auto-discovered).
  5. Add unit tests in filters.rs:

    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn full_iso_datetime() {
            assert_eq!(
                format_date("2026-03-19T20:54:36.175264Z").unwrap(),
                "Mar 19, 2026"
            );
        }
    
        #[test]
        fn datetime_no_frac() {
            assert_eq!(
                format_date("2026-03-19T20:54:36Z").unwrap(),
                "Mar 19, 2026"
            );
        }
    
        #[test]
        fn date_only() {
            assert_eq!(
                format_date("2026-03-19").unwrap(),
                "Mar 19, 2026"
            );
        }
    
        #[test]
        fn truncated_input_fallback() {
            assert_eq!(
                format_date("2026-03-19...").unwrap(),
                "Mar 19, 2026"
            );
        }
    
        #[test]
        fn garbage_fallback() {
            assert_eq!(
                format_date("not-a-date").unwrap(),
                "not-a-date"
            );
        }
    }

Step 3: Askama enum humanize filter

Files: services/craig-web/src/filters.rs

Add the humanize function to the existing filters module:

/// Convert a snake_case string to Title Case.
/// "foster_care" → "Foster Care", "24_hour" → "24 Hour"
pub fn humanize(s: &str) -> askama::Result<String> {
    let result = s
        .split('_')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(c) => {
                    let upper: String = c.to_uppercase().collect();
                    format!("{upper}{}", chars.as_str())
                }
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ");
    Ok(result)
}

Add unit tests:

#[test]
fn humanize_snake_case() {
    assert_eq!(humanize("foster_care").unwrap(), "Foster Care");
}

#[test]
fn humanize_single_word() {
    assert_eq!(humanize("active").unwrap(), "Active");
}

#[test]
fn humanize_numeric_prefix() {
    assert_eq!(humanize("24_hour").unwrap(), "24 Hour");
}

#[test]
fn humanize_three_words() {
    assert_eq!(humanize("pending_supervisor_review").unwrap(), "Pending Supervisor Review");
}

Step 4: Apply date + enum filters across all templates

Files: All *.html templates under services/craig-web/templates/

Systematic replacement across all templates. Use grep -rn 'truncate(10)' templates/ to find all date truncations; replace {{ field|truncate(10) }} with {{ field|format_date }}.

For raw date fields that don’t use any filter (e.g., {{ claim.created_at }}), add |format_date.

For enum fields that display raw values, add |humanize. The affected templates and fields:

Template Field Filter to Apply

cases/_tab_summary.html:26

case.opened_at|truncate(10)

case.opened_at|format_date

cases/_tab_summary.html:30

closed|truncate(10)

closed|format_date

cases/_tab_summary.html:9

case.status (in kv-grid)

case.status|humanize

cases/_tab_summary.html:12

case.stage

case.stage|humanize

cases/list.html:86

c.opened_at|truncate(10)

c.opened_at|format_date

financial/claim_detail.html:27

claim.payment_type

claim.payment_type|humanize

financial/claim_detail.html:50

claim.created_at

claim.created_at|format_date

financial/claim_detail.html:46

at (submitted_at)

at|format_date

financial/claims.html

c.payment_type

c.payment_type|humanize

financial/payments.html

p.type_ (payment type column)

p.type_|humanize

financial/payment_detail.html

payment.payment_type, date fields

|humanize and |format_date

exchange/icpc_detail.html

icpc.request_type, icpc.submitted_at, icpc.created_by

|humanize and |format_date

exchange/icpc.html

r.request_type, r.submitted_at

|humanize and |format_date

exchange/agreements.html

a.effective_date, a.expiration

|format_date

exchange/transactions.html

t.initiated_at, t.type_

|format_date and |humanize

exchange/partner_detail.html

date fields, type fields

|format_date and |humanize

intake/referrals.html

r.received_at|truncate(10)

r.received_at|format_date

intake/investigations.html

i.response_due|truncate(10), etc.

|format_date

intake/investigation_detail.html

inv.response_due, inv.first_contact

|format_date

intake/reports.html

r.submitted_at

r.submitted_at|format_date

intake/report_detail.html

date fields, type fields

|format_date and |humanize

placement/placements.html

p.started_at|truncate(10), p.ended_at|truncate(10)

|format_date

placement/homes.html

type column

|humanize

placement/home_detail.html

date fields, type fields

|format_date and |humanize

placement/matching.html

type column

|humanize

security/audit.html

e.timestamp

e.timestamp|format_date

security/reviews.html

r.scheduled, r.completed

|format_date

security/nist.html

c.assessed_at

|format_date

rules/list.html

r.updated_at

r.updated_at|format_date

Full audit: run grep -rn 'truncate(10)\|\.created_at\|\.updated_at\|\.opened_at\|\.submitted_at\|\.initiated_at\|\.received_at\|\.started_at\|\.ended_at\|\.response_due\|\.scheduled\|\.completed\|\.assessed_at\|\.effective_date\|\.expiration' templates/ and apply |format_date to each.

For enum fields: run grep -rn '\.payment_type\|\.request_type\|\.type_\|\.status\|\.stage\|\.closure_reason\|\.direction\|\.format\|\.partner_type' templates/ and apply |humanize where the value is displayed as plain text in a kv-grid (NOT in badge if/else blocks which already map to human labels).

Step 5: Fix exchange partner detail 404

Files: services/craig-web/src/routes/exchange.rs (lines 624-692)

Replace the list-and-search pattern with a direct API call:

// BEFORE (lines 637-652):
let partners_path = "/v1/exchange/partners?page=1&per_page=100".to_string();
let (partners_res, agreements_res, transactions_res) = tokio::join!(
    state.api.get(exchange_url, &partners_path, token),
    ...
);
let partner: PartnerView = match partners_res.ok().and_then(|v| {
    serde_json::from_value::<PageResponse<PartnerView>>(v)
        .ok()
        .and_then(|p| p.data.into_iter().find(|pp| pp.id == id))
}) { ... };

// AFTER:
let partner_path = format!("/v1/exchange/partners/{id}");
let (partner_res, agreements_res, transactions_res) = tokio::join!(
    state.api.get(exchange_url, &partner_path, token),
    state.api.get(exchange_url, &agreements_path, token),
    state.api.get(exchange_url, &transactions_path, token),
);
let partner: PartnerView = match partner_res
    .ok()
    .and_then(|v| serde_json::from_value::<PartnerView>(v).ok())
{
    Some(p) => p,
    None => return render_not_found(),
};

This eliminates the pagination limit bug and is more efficient (1 API call vs listing all partners).

Step 6: Consistent status badge mappings

Files: All list and detail templates that display status badges

Audit every template for status badge rendering. Ensure all statuses follow a consistent mapping pattern:

Category Status Values → CSS Class Label

Positive/complete

active, open, approved, accepted, completed, cleared, issued

badge—​done (green fill)

In-progress/pending

pending, draft, planned, submitted, screening, pending_review, transferred

badge—​pending (light tint)

Alert/negative

denied, failed, voided, overdue, expired, screened_out, closed

badge—​overdue (red) for denied/failed/voided/expired; badge—​done for closed (completed)

Warning

24_hour, immediate (priorities)

badge—​warn for 24-hour, badge—​overdue for immediate

Special

icwa

badge—​icwa (yellow)

Templates to update (ensure all have complete if/elif/else chains):

  • financial/claims.html — currently falls through to plain text for some statuses; add full badge mapping

  • financial/payments.html — uses |capitalize as fallback; replace with explicit mapping

  • financial/payment_detail.html — same

  • exchange/icpc.html — some statuses rendered as plain text; add badge markup

  • intake/reports.htmlscreening and screened_out need consistent badge classes

  • placement/placements.html — verify ended uses badge—​done not badge—​overdue

  • security/reviews.html — verify completed vs scheduled mapping

  • security/nist.htmlimplemented vs planned vs partial need badges

For any template using {{ status|capitalize }} as fallback, replace with {{ status|humanize }} inside a <span class="badge"> tag so the styling is always applied.

Step 7: Form section header consistency

Files: services/craig-web/templates/cases/new_case.html, services/craig-web/templates/placement/new_home.html, services/craig-web/templates/placement/new_placement.html, services/craig-web/templates/exchange/new_partner.html, services/craig-web/templates/financial/new_rate.html

The new_referral.html template already uses the correct pattern:

<h2 class="card__subheading mb-16">REPORTER INFORMATION</h2>
<!-- fields -->
<hr class="card__divider">
<h2 class="card__subheading mb-16">REFERRAL DETAILS</h2>
<!-- fields -->

Apply this pattern to all forms that lack section headers:

Template Section Headers to Add

new_case.html

"CASE DETAILS" above admin unit / assigned worker row

new_home.html

"HOME INFORMATION" above home name / admin unit; "LICENSING" above license type / capacity; "PREFERENCES" above checkboxes

new_placement.html

"CASE & CHILD" above case/child fields; "PLACEMENT DETAILS" above foster home / type / dates

new_partner.html

"PARTNER INFORMATION" above partner name / type; "CONNECTIVITY" above endpoint URL / format

new_rate.html

"RATE DETAILS" above jurisdiction / payment type; "AGE RANGE" above age min / max; "AMOUNT & DATES" above daily rate / dates

Each group of related fields gets a cardsubheading + a carddivider between sections.

Step 8: New Placement form — replace raw UUID inputs with searchable selects

Files: services/craig-web/templates/placement/new_placement.html, services/craig-web/src/routes/placement.rs

Template changes (new_placement.html)

Replace the Case ID text input with a <select> populated from server-rendered data:

<div class="form-group">
  <label class="form-label" for="case_id">Case</label>
  <select class="form-input" id="case_id" name="case_id" required
          hx-get="/placement/api/case-children"
          hx-target="#child_id"
          hx-swap="innerHTML"
          hx-trigger="change"
          hx-include="[name='case_id']">
    <option value="">— Select Case —</option>
    {% for c in cases %}
    <option value="{{ c.id }}">{{ c.case_number }} ({{ c.admin_unit }})</option>
    {% endfor %}
  </select>
</div>
<div class="form-group">
  <label class="form-label" for="child_id">Child</label>
  <select class="form-input" id="child_id" name="child_id" required>
    <option value="">— Select a case first —</option>
  </select>
</div>

Route changes (placement.rs)

  1. In the new_placement_form handler, fetch open cases:

    let cases_path = "/v1/cases?status=open&page=1&per_page=200";
    let cases_res = state.api.get(&state.config.cases_url, cases_path, token).await;
    let cases: Vec<CaseListItem> = cases_res
        .ok()
        .and_then(|v| serde_json::from_value::<PageResponse<CaseListItem>>(v).ok())
        .map(|p| p.data)
        .unwrap_or_default();

    Pass cases to the template struct.

  2. Add an htmx fragment endpoint for child lookup:

    /// GET /placement/api/case-children?case_id={uuid}
    /// Returns <option> elements for children in the given case.
    pub async fn case_children_options(
        session: Session,
        State(state): State<AppState>,
        Query(params): Query<CaseChildrenParams>,
    ) -> axum::response::Response {
        let user = get_session_user(&session).await.unwrap_or_default();
        let token = &user.access_token;
        let path = format!("/v1/cases/{}/persons?role=child&per_page=100", params.case_id);
        let res = state.api.get(&state.config.cases_url, &path, token).await;
        let children: Vec<PersonView> = res
            .ok()
            .and_then(|v| serde_json::from_value::<PageResponse<PersonView>>(v).ok())
            .map(|p| p.data)
            .unwrap_or_default();
    
        let html: String = children.iter().map(|c| {
            format!(r#"<option value="{}">{} {}</option>"#, c.id, c.first_name, c.last_name)
        }).collect();
    
        axum::response::Html(html).into_response()
    }
  3. Register the route in main.rs:

    .route("/placement/api/case-children", get(routes::placement::case_children_options))

Step 9: Dashboard stat cards — wire live counts and remove stale copy

Files: services/craig-web/templates/dashboard.html, services/craig-web/src/routes/dashboard.rs

Route changes (dashboard.rs)

In the dashboard handler, add parallel API calls to fetch counts:

let (cases_res, inv_res, placements_res, reviews_res) = tokio::join!(
    state.api.get(&state.config.cases_url, "/v1/cases?page=1&per_page=1", token),
    state.api.get(&state.config.cases_url, "/v1/investigations?status=open&page=1&per_page=1", token),
    state.api.get(&state.config.placement_url, "/v1/placements?status=active&page=1&per_page=1", token),
    state.api.get(&state.config.security_url, "/v1/security/reviews?status=scheduled&page=1&per_page=1", token),
);

fn extract_total(res: Result<Value, _>) -> Option<i64> {
    res.ok()
        .and_then(|v| v.get("total")?.as_i64())
}

let total_cases = extract_total(cases_res);
let open_investigations = extract_total(inv_res);
let active_placements = extract_total(placements_res);
let pending_reviews = extract_total(reviews_res);

Pass these Option<i64> values to the template struct.

Template changes (dashboard.html)

Replace the em-dash placeholders:

<div class="stat-tile">
  <div class="stat-tile__label">Total Cases</div>
  <div class="stat-tile__value">
    {% if let Some(n) = total_cases %}{{ n }}{% else %}&mdash;{% endif %}
  </div>
  <div class="stat-tile__sub">system-wide</div>
</div>

Repeat for all four tiles.

Remove the stale paragraph:

-<p class="text-muted mt-16">
-  Dashboard data will populate as modules are implemented.
-  Use the navigation bar above to explore available modules.
-</p>

Step 10: Pagination partial — extract shared template

Files: services/craig-web/templates/_pagination.html (new), all list templates

Create templates/_pagination.html:

{% if total_pages > 1 %}
<nav class="pagination-bar" aria-label="Pagination">
  {% if page > 1 %}
    <a href="{{ base_url }}?page={{ page - 1 }}{{ extra }}" class="pagination-bar__page" aria-label="Previous page">&laquo;</a>
  {% else %}
    <span class="pagination-bar__page pagination-bar__page--disabled">&laquo;</span>
  {% endif %}

  {% if total_pages <= 7 %}
    {% for p in 1..=total_pages %}
      {% if p == page %}
        <span class="pagination-bar__page pagination-bar__page--active" aria-current="page">{{ p }}</span>
      {% else %}
        <a href="{{ base_url }}?page={{ p }}{{ extra }}" class="pagination-bar__page">{{ p }}</a>
      {% endif %}
    {% endfor %}
  {% else %}
    {% if page == 1 %}
      <span class="pagination-bar__page pagination-bar__page--active" aria-current="page">1</span>
    {% else %}
      <a href="{{ base_url }}?page=1{{ extra }}" class="pagination-bar__page">1</a>
    {% endif %}
    {% if win_start > 2 %}
      <span class="pagination-bar__ellipsis">&hellip;</span>
    {% endif %}
    {% for p in win_start..=win_end %}
      {% if p == page %}
        <span class="pagination-bar__page pagination-bar__page--active" aria-current="page">{{ p }}</span>
      {% else %}
        <a href="{{ base_url }}?page={{ p }}{{ extra }}" class="pagination-bar__page">{{ p }}</a>
      {% endif %}
    {% endfor %}
    {% if win_end < total_pages - 1 %}
      <span class="pagination-bar__ellipsis">&hellip;</span>
    {% endif %}
    {% if page == total_pages %}
      <span class="pagination-bar__page pagination-bar__page--active" aria-current="page">{{ total_pages }}</span>
    {% else %}
      <a href="{{ base_url }}?page={{ total_pages }}{{ extra }}" class="pagination-bar__page">{{ total_pages }}</a>
    {% endif %}
  {% endif %}

  {% if page < total_pages %}
    <a href="{{ base_url }}?page={{ page + 1 }}{{ extra }}" class="pagination-bar__page" aria-label="Next page">&raquo;</a>
  {% else %}
    <span class="pagination-bar__page pagination-bar__page--disabled">&raquo;</span>
  {% endif %}

  <span class="pagination-bar__info">{{ total }} total</span>
</nav>
{% endif %}

Each list template struct must define base_url: &'static str (e.g., "/cases/", "/intake/referrals") and the existing page, total_pages, total, extra fields.

Replace the duplicated pagination blocks in each list template with:

{% include "_pagination.html" %}

Templates to update (grep for pagination-bar):

  • cases/list.html

  • intake/referrals.html

  • intake/investigations.html

  • intake/reports.html

  • placement/homes.html

  • placement/placements.html

  • placement/matching.html

  • exchange/partners.html

  • exchange/agreements.html

  • exchange/transactions.html

  • exchange/icpc.html

  • financial/payments.html

  • financial/rates.html

  • financial/claims.html

  • rules/list.html

  • security/audit.html

  • security/reviews.html

  • security/nist.html

Step 11: Seed archive records for documentation screenshots

Files: tools/craig-seed/src/security.rs (or equivalent seed module)

Add seed data generation for at least one archive record in the craig_security database so the Security Archive page (43-security-archive.png) shows populated data instead of "No archive records found."

The archive record should include realistic values:

  • source_service: craig-cases

  • source_table: contacts

  • record_count: 150

  • archived_at: seed-relative date (e.g., 30 days ago)

  • retention_until: seed-relative date (e.g., 7 years from archived_at)

  • purge_eligible: false

  • purged_at: None

Step 12: Regenerate screenshots and update E2E assertions

Files: tests/e2e/specs/screenshots.spec.ts, tests/e2e/specs/*.spec.ts

  1. Rebuild devstack: cargo xtask dev reload

  2. Re-run seeds: ensure CRAIG_SEED=42 CRAIG_FAMILIES=12 is applied

  3. Regenerate screenshots: SCREENSHOTS=1 docker compose --profile e2e run --build --rm craig-e2e --project=setup --project=screenshots

  4. Verify all 47 PNGs have valid headers: xxd -l 8 -p <file> = 89504e470d0a1a0a

  5. Update any E2E assertions that match on old date/enum formatting:

    • Assertions matching foster_care text should now match Foster Care

    • Assertions matching date patterns like 2025-03-19 should match Mar 19, 2025

    • The exchange partner detail test should now show actual partner data instead of 404

Step 13: Documentation updates, issue, commit, push, MR

Files: CHANGELOG.adoc, .claude/docs/services.md, docs/modules/ROOT/pages/plans/ui-consistency-hardening.adoc

  1. Add CHANGELOG.adoc entry under == Unreleased:

    === Changed
    * Web UI: all dates formatted as "Mon DD, YYYY" instead of truncated ISO strings
    * Web UI: snake_case enum values displayed as Title Case across all pages
    * Web UI: consistent status badge styling across all modules
    * Web UI: form section headers standardized with card__subheading pattern
    * Web UI: dashboard stat cards display live counts from API
    * Web UI: new placement form uses case/child dropdown selectors
    * Web UI: pagination extracted to shared template partial
    
    === Fixed
    * Web UI: exchange partner detail page returned 404 due to list pagination limit
  2. Update .claude/docs/services.md if any new endpoints were added (the htmx child-lookup endpoint is internal to craig-web, so only document it if exposed)

  3. Update user guides if affected:

    • guide/caseworker.adoc — mention case/child picker on new placement

    • guide/admin.adoc — mention live dashboard stats

  4. Update this plan’s status table to all Complete

  5. Create GitLab issue, commit on feature branch with Closes #N, push, create MR

Files Touched

File Change

services/craig-web/Cargo.toml

Add chrono dependency

services/craig-web/src/filters.rs

New file: format_date and humanize custom Askama filters

services/craig-web/src/main.rs

Declare mod filters;, add /placement/api/case-children route

services/craig-web/src/routes/exchange.rs

Fix partner detail to use direct GET instead of list-and-search

services/craig-web/src/routes/dashboard.rs

Add parallel API calls for stat card counts

services/craig-web/src/routes/placement.rs

Add case_children_options handler, pass cases to new placement template

services/craig-web/templates/_pagination.html

New shared pagination partial

services/craig-web/templates/dashboard.html

Wire stat values, remove stale copy

services/craig-web/templates/placement/new_placement.html

Replace UUID text inputs with select dropdowns + htmx child picker

services/craig-web/templates/cases/new_case.html

Add section headers

services/craig-web/templates/placement/new_home.html

Add section headers

services/craig-web/templates/placement/new_placement.html

Add section headers

services/craig-web/templates/exchange/new_partner.html

Add section headers

services/craig-web/templates/financial/new_rate.html

Add section headers

~18 list templates

Replace inline pagination with {% include "_pagination.html" %}, add base_url

~30 templates (list + detail)

Apply |format_date and |humanize filters to date/enum fields

~12 templates (list + detail)

Normalize status badge if/elif/else chains

tools/craig-seed/src/security.rs

Add archive record seed data

tests/e2e/specs/screenshots.spec.ts

Re-run for regenerated screenshots

tests/e2e/specs/*.spec.ts

Update assertions for new date/enum formatting

CHANGELOG.adoc

Entry under Unreleased

docs/modules/ROOT/nav.adoc

Add plan link under Planned

This plan file

Update status to Complete

Verification

  1. cargo nextest run --workspace --lib — unit tests pass (including new filter tests)

  2. cargo xtask dev reload

  3. cargo nextest run --workspace — integration tests pass

  4. cargo xtask e2e — all E2E tests pass

  5. SCREENSHOTS=1 docker compose --profile e2e run --build --rm craig-e2e --project=setup --project=screenshots — all 47 screenshots regenerate successfully

  6. Visual inspection: open each screenshot and verify:

    • All dates display as "Mon DD, YYYY"

    • No raw snake_case enum values visible

    • All status values have badge styling

    • Exchange partner detail shows actual partner data (not 404)

    • Dashboard shows numeric counts (not em-dashes)

    • New Placement form shows case/child dropdowns

    • All forms have consistent section headers

    • Security Archive page shows seeded data

  7. All PNGs pass magic-byte validation (89504e47…​)

Documentation Updates

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/docs/services.md — add htmx child-lookup endpoint if publicly routed

  • docs/modules/ROOT/pages/guide/caseworker.adoc — case/child picker on new placement

  • docs/modules/ROOT/pages/guide/admin.adoc — live dashboard stats

  • docs/modules/ROOT/nav.adoc — plan link added (Step 1)

  • This plan — status updated to Complete

  • .claude/CLAUDE.md Phase Status table — update Phase 10 stats if applicable

Edit this page · latest