Plan: UI Consistency Hardening
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Plan file and nav entry
- Step 2: Askama date formatting filter
- Step 3: Askama enum humanize filter
- Step 4: Apply date + enum filters across all templates
- Step 5: Fix exchange partner detail 404
- Step 6: Consistent status badge mappings
- Step 7: Form section header consistency
- Step 8: New Placement form — replace raw UUID inputs with searchable selects
- Step 9: Dashboard stat cards — wire live counts and remove stale copy
- Step 10: Pagination partial — extract shared template
- Step 11: Seed archive records for documentation screenshots
- Step 12: Regenerate screenshots and update E2E assertions
- Step 13: Documentation updates, issue, commit, push, MR
- Files Touched
- Verification
- Documentation Updates
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:
-
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. -
Enum display — API enum values displayed as raw snake_case (
foster_care,physical_abuse) instead of human-readable labels ("Foster Care", "Physical Abuse"). -
Status badges — inconsistent use of badge CSS classes across modules; some statuses fall through to raw text or
|capitalizeinstead of proper badge mapping. -
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 directGET /v1/exchange/partners/{id}endpoint that should be used instead. -
Form section headers — some forms use
<h2 class="card__subheading">(uppercase, dark-green, small font) and some have no section headers at all. -
New Placement form — requires raw UUID input for Case ID and Child ID, which is unusable.
-
Dashboard — stat cards show placeholder em-dashes and stale copy ("Dashboard data will populate as modules are implemented") even though all modules are complete.
-
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+carddividersection 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_care→Foster Care -
physical_abuse→Physical Abuse -
pending_review→Pending Review -
24_hour→24 Hour -
screened_out→Screened 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→ usetotalfromPageResponse -
Open Investigations:
GET /v1/investigations?status=open&page=1&per_page=1→ usetotal -
Active Placements:
GET /v1/placements?status=active&page=1&per_page=1→ usetotal -
Pending Reviews:
GET /v1/security/reviews?status=scheduled&page=1&per_page=1→ usetotal
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:
-
Case ID:
<select>dropdown populated fromGET /v1/cases?status=open&per_page=100showing case numbers. -
Child ID: dynamic
<select>that populates via htmx when a case is selected — callsGET /v1/cases/{case_id}/persons?role=childto 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)
-
Add
chronodependency tocraig-web/Cargo.toml(features:[], noserdeneeded — parsing only):chrono = { version = "0.4", default-features = false, features = ["std"] } -
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()) } -
Declare the module in
main.rs:mod filters; -
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).
-
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
date fields, type fields |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
date fields, type fields |
|
|
|
|
|
type column |
|
|
date fields, type fields |
|
|
type column |
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
In-progress/pending |
|
|
Alert/negative |
|
|
Warning |
|
|
Special |
|
|
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|capitalizeas fallback; replace with explicit mapping -
financial/payment_detail.html— same -
exchange/icpc.html— some statuses rendered as plain text; add badge markup -
intake/reports.html—screeningandscreened_outneed consistent badge classes -
placement/placements.html— verifyendedusesbadge—donenotbadge—overdue -
security/reviews.html— verifycompletedvsscheduledmapping -
security/nist.html—implementedvsplannedvspartialneed 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 |
|---|---|
|
"CASE DETAILS" above admin unit / assigned worker row |
|
"HOME INFORMATION" above home name / admin unit; "LICENSING" above license type / capacity; "PREFERENCES" above checkboxes |
|
"CASE & CHILD" above case/child fields; "PLACEMENT DETAILS" above foster home / type / dates |
|
"PARTNER INFORMATION" above partner name / type; "CONNECTIVITY" above endpoint URL / format |
|
"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)
-
In the
new_placement_formhandler, 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
casesto the template struct. -
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() } -
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 %}—{% 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">«</a>
{% else %}
<span class="pagination-bar__page pagination-bar__page--disabled">«</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">…</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">…</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">»</a>
{% else %}
<span class="pagination-bar__page pagination-bar__page--disabled">»</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
-
Rebuild devstack:
cargo xtask dev reload -
Re-run seeds: ensure
CRAIG_SEED=42 CRAIG_FAMILIES=12is applied -
Regenerate screenshots:
SCREENSHOTS=1 docker compose --profile e2e run --build --rm craig-e2e --project=setup --project=screenshots -
Verify all 47 PNGs have valid headers:
xxd -l 8 -p <file>=89504e470d0a1a0a -
Update any E2E assertions that match on old date/enum formatting:
-
Assertions matching
foster_caretext should now matchFoster Care -
Assertions matching date patterns like
2025-03-19should matchMar 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
-
Add
CHANGELOG.adocentry 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
-
Update
.claude/docs/services.mdif any new endpoints were added (the htmx child-lookup endpoint is internal to craig-web, so only document it if exposed) -
Update user guides if affected:
-
guide/caseworker.adoc— mention case/child picker on new placement -
guide/admin.adoc— mention live dashboard stats
-
-
Update this plan’s status table to all Complete
-
Create GitLab issue, commit on feature branch with
Closes #N, push, create MR
Files Touched
| File | Change |
|---|---|
|
Add |
|
New file: |
|
Declare |
|
Fix partner detail to use direct GET instead of list-and-search |
|
Add parallel API calls for stat card counts |
|
Add |
|
New shared pagination partial |
|
Wire stat values, remove stale copy |
|
Replace UUID text inputs with select dropdowns + htmx child picker |
|
Add section headers |
|
Add section headers |
|
Add section headers |
|
Add section headers |
|
Add section headers |
~18 list templates |
Replace inline pagination with |
~30 templates (list + detail) |
Apply |
~12 templates (list + detail) |
Normalize status badge if/elif/else chains |
|
Add archive record seed data |
|
Re-run for regenerated screenshots |
|
Update assertions for new date/enum formatting |
|
Entry under Unreleased |
|
Add plan link under Planned |
This plan file |
Update status to Complete |
Verification
-
cargo nextest run --workspace --lib— unit tests pass (including new filter tests) -
cargo xtask dev reload -
cargo nextest run --workspace— integration tests pass -
cargo xtask e2e— all E2E tests pass -
SCREENSHOTS=1 docker compose --profile e2e run --build --rm craig-e2e --project=setup --project=screenshots— all 47 screenshots regenerate successfully -
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
-
-
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.mdPhase Status table — update Phase 10 stats if applicable