diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf19c5f77..5c116c043 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,8 +491,11 @@ measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row -does not claim a calibrated measurement. A pending lineage row -says reconstruction has not started yet. The +does not claim a calibrated measurement and does not say +reconstruction. The list button accessible name includes the +next-action sentence; detail repeats it. Calendar, period-report, +and post-list buttons follow the same AccName rule for their +visible due date, θ, and labels (ADR 0024). The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · @@ -572,7 +575,10 @@ bank as the dummy high/low band rows, so comparison-strip click through opens those DAG posts. Report members include the earliest open ticket title, status lookup label, and due date when one exists. The home page renders the actual mean θ, the FIPC delta, the CAT-selected item, and the -PU / corp / thread comparison -- never a placeholder. TEPP is unchanged. +PU / corp / thread comparison -- never a placeholder. Those same +facts stay in each list-button accessible name (ADR 0024) so a +screen reader can pick the due commitment or the higher-θ post. +TEPP is unchanged. ## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer diff --git a/CHANGELOG.d/0.87.1-audible-list-next-actions.md b/CHANGELOG.d/0.87.1-audible-list-next-actions.md new file mode 100644 index 000000000..94862a886 --- /dev/null +++ b/CHANGELOG.d/0.87.1-audible-list-next-actions.md @@ -0,0 +1,2 @@ +List-button accessible names include the next-action sentence. +Pending TEPP corpus copy does not say the run has already started. diff --git a/CHANGELOG.d/0.87.2-home-list-accessible-names.md b/CHANGELOG.d/0.87.2-home-list-accessible-names.md new file mode 100644 index 000000000..5dee608c8 --- /dev/null +++ b/CHANGELOG.d/0.87.2-home-list-accessible-names.md @@ -0,0 +1 @@ +Home calendar, report, and post-list accessible names include the visible due date, θ, status, and labels. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a19fe92..0bd1b5140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.87.2] - 2026-08-16 + +### Fixed + +- Home calendar, period-report, and post-list buttons keep the visible + due date, θ, status, and labels in the accessible name (WCAG 2.2 + SC 4.1.2). Open a commitment: the name includes “due 2026-01-12”. + Open a report post: the name includes θ and the ticket. A post-list + name includes Voice of Customer and Public. + +## [0.87.1] - 2026-08-16 + +### Fixed + +- Analysis-run list buttons keep the next-action sentence in the + accessible name (WCAG 2.2 SC 4.1.2). Open a Failed TEPP row: the + name includes “connect the measurement service.” A Pending TEPP + corpus no longer says the run has already started finishing. + ## [0.87.0] - 2026-08-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 870c77f87..af03ec8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,13 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. +pending TEPP row does not claim a calibrated measurement and does +not say reconstruction. A pending lineage row still says +reconstruction has not started. The list button name includes the +next-action sentence. +Home calendar, period-report, and post-list buttons include the +visible due date, θ, status, and labels in the accessible name +(ADR 0024). Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 500c2bc2a..17d9b6b31 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,8 +48,11 @@ service. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated -measurement. A pending lineage row says reconstruction has not -started yet. The detail now shows the legal +measurement and must not say reconstruction. A pending lineage row +says reconstruction has not started yet. The list button accessible +name includes the next-action sentence (WCAG 2.2 SC 4.1.2); detail +repeats it so opening a Failed or Pending row still tells the +operator what to do. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run @@ -65,3 +68,11 @@ Educational Research Association. Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/2013/REC-prov-o-20130430/ + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/adr/0024-home-list-accessible-names.md b/docs/adr/0024-home-list-accessible-names.md new file mode 100644 index 000000000..d9717d492 --- /dev/null +++ b/docs/adr/0024-home-list-accessible-names.md @@ -0,0 +1,57 @@ +# ADR 0024 — Home list accessible names include the visible next facts + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0014 analysis-run list AccName +**Refs:** Issue #79 (Milestone 2 parent); WCAG 2.2 SC 4.1.2 + +## Context + +ADR 0014 put the analysis-run next-action sentence in the list-button +accessible name after `aria-label` hid it. The same replacement still +hid the next facts on the other home lists: + +- Calendar heard only the post title, not the commitment, status, or due date. +- Period-index buttons heard only the week code, not mean θ or the FIPC delta. +- Report-member buttons heard only the post title, not θ, ticket, or due date. +- Grouping-comparison buttons heard the raw `grouping_kind` wire code. +- The post list heard only the title, not VOC or visibility. + +A buyer using a screen reader could not choose the commitment that is +due or the report post with the higher θ. + +Parallel PRs may claim ADR 0021 (person catalog) and ADR 0023 +(analysis-run home tokens). This decision uses the next free slot. + +## Decision + +Home list `aria-label` values include the same facts the sighted +operator already sees: + +- Calendar: `Open commitment for: {post}. {summary}. {status}. due {date}` +- Period index: `Open report period {code}. mean θ {n}. {delta or shared metric}. CAT: …` +- Report member: `Open report post: {title}. θ {n}. {ticket}. {status}. due {date}` +- Comparison: `Compare {kind label}: {label}. mean θ {n}. {count} posts` +- Post list: `View post: {title}. {voc label}. {visibility label}.` + +Copy uses lookup labels, not raw status or grouping wire codes. This +slice does not invent a theta, start reconstruction, or add a TEPP +transport. + +## Consequences + +After `make seed`, the Demo Corp commitment name includes +`due 2026-01-12`, the 2026-W03 period name includes `vs 2026-W02: +0.92`, +and the Public post list name includes Voice of Customer and Public. +Analysis-run AccName stays ADR 0014. Digest hover disclosure stays a +later slice. + +## References + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index c776053b1..223d70696 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,7 @@ |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | | W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | -| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | +| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. List-button names include the kind-specific next-action sentence (ADR 0014; WCAG 2.2 SC 4.1.2). Home calendar, report, and post-list names follow ADR 0024. | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, `invoking_session_role` on each retention event, and exclusion of raw source/provider payloads. | @@ -117,5 +117,8 @@ Recommendation). https://www.w3.org/TR/prov-o/ World Wide Web Consortium. (2018). *Accessible name and description computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/docs/doctoring/HOME_LIST_ACCESSIBLE_NAME_REFERENCES.md b/docs/doctoring/HOME_LIST_ACCESSIBLE_NAME_REFERENCES.md new file mode 100644 index 000000000..c5a8081e3 --- /dev/null +++ b/docs/doctoring/HOME_LIST_ACCESSIBLE_NAME_REFERENCES.md @@ -0,0 +1,44 @@ +# Home-list accessible-name standards and research traceability + +**Status:** Active PR evidence; not protected-main truth until merge. +**Scope:** Calendar, period-report, comparison, and post-list buttons on +the product home page (ADR 0024). Analysis-run list names remain ADR 0014. + +## Standards mapped to implementation + +| Source | Product implication | Implemented evidence | +|---|---|---| +| W3C Accessible Name and Description Computation 1.1 | `aria-label` replaces descendant text; the name must include every visible fact the operator uses to choose a row. | `calendarCommitmentAccessibleName`, `reportPeriodAccessibleName`, `reportPostAccessibleName`, `reportCompareAccessibleName`, and `postListAccessibleName` in `frontend/src/App.tsx`. | +| WCAG 2.2 Success Criterion 4.1.2 Name, Role, Value | A button's programmatic name must identify the control. | Tests lock the Demo Corp due date, report θ, FIPC delta, and VOC/visibility labels in `getByRole` names. | +| ISO 8601-1:2019 | Due dates stay calendar dates, not localized prose. | Seeded commitments use `due 2026-01-12` / `due 2026-01-14`. | +| AERA, APA, & NCME (2014) *Standards* | Report θ remains a labeled estimate from TEPP/fast-mlsirm persistence; this slice does not invent a score. | AccName repeats the already-calibrated `theta_eap` / `mean_theta` the API returned. | + +## Verification matrix + +| Claim | Falsifiable test | +|---|---| +| Calendar name includes due date | `Open commitment for: Public post. … due 2026-01-12` is the button name. | +| Period name includes θ and FIPC delta | `Open report period 2026-W03. mean θ 0.92. vs 2026-W02: +0.92. …` is the button name. | +| Shared-metric week does not invent a delta | `Open report period 2026-W02. mean θ 0.00. shared metric. …` is the button name. | +| Report post name includes θ and ticket | `Open report post: Public post. θ 0.91. Send Northridge Grid the revised quote. Open. due 2026-01-12`. | +| Comparison uses the kind label, not the wire code | `Compare Process unit: Demo Report High. mean θ 0.81. 4 posts`. | +| Post list includes VOC and visibility | `View post: Public post. Voice of Customer. Public.` | + +## APA 7th references + +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/frontend/package.json b/frontend/package.json index 0d43d9fa2..d1c9c0b84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.87.0", + "version": "0.87.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fd8a15146..0ce7113b8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -61,8 +61,11 @@ describe("App, authenticated", () => { verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; failedReportRun?: boolean; + pendingReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + runningTeppRun?: boolean; + cancelledTeppRun?: boolean; postBody?: string; }) { const statusLabel: Record = { @@ -86,6 +89,28 @@ describe("App, authenticated", () => { const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; let createdPendingLineage: Record | null = null; + const pendingLineageRun = { + analysis_run_id: "run-demo-lineage-pending", + run_kind_code: "analysis_run_lineage" as const, + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_pending" as const, + status_label: "Pending", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:35:00Z", + source_counts: [] as { count_type_code: string; count_type_label: string; count_value: number }[], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ], + }; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -176,6 +201,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { + const reportPending = Boolean(options?.pendingReportRun); return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -184,8 +210,8 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: reportPending ? "analysis_status_pending" : "analysis_status_failed", + status_label: reportPending ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -196,35 +222,55 @@ describe("App, authenticated", () => { }, ], visible_posts: [], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:39:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:40:00Z", - failure_code: "period_report_rebuild_failed", - }, - ], + status_history: reportPending + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", + }, + ], }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending")) { + return Promise.resolve(jsonResponse(pendingLineageRun)); + } if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { const teppStatus = options?.succeededTeppRun ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed"; + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed"; const teppLabel = options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed"; + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed"; return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-tepp", @@ -254,31 +300,57 @@ describe("App, authenticated", () => { occurred_at: "2026-01-12T12:35:00Z", }, ] - : [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_running", - status_label: "Running", - occurred_at: "2026-01-12T12:36:00Z", - }, - { - status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), - }, - ], + : options?.runningTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : options?.cancelledTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_cancelled", + status_label: "Cancelled", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: teppStatus, + status_label: teppLabel, + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun ? {} : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -291,8 +363,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -321,39 +395,22 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", occurred_at: "2026-01-12T12:33:00Z", + ...(options?.failedLineageRun + ? { failure_code: "lineage_reconstruction_failed" } + : {}), }, ], }), ); } if (url.endsWith("/api/analysis-runs") && method === "POST") { - const created = { - analysis_run_id: "run-demo-lineage-pending", - run_kind_code: "analysis_run_lineage", - run_kind_label: "Lineage reconstruction", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: "analysis_status_pending", - status_label: "Pending", - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:35:00Z", - source_counts: [], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - ], - }; - createdPendingLineage = created; - return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); + createdPendingLineage = pendingLineageRun; + return Promise.resolve(new Response(JSON.stringify(pendingLineageRun), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { return Promise.resolve( @@ -395,12 +452,20 @@ describe("App, authenticated", () => { ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed", + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed", status_label: options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed", + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -411,7 +476,7 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(options?.failedReportRun || options?.pendingReportRun ? [ { analysis_run_id: "run-demo-report", @@ -420,8 +485,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed" as const, - status_label: "Failed", + status_code: options?.pendingReportRun + ? ("analysis_status_pending" as const) + : ("analysis_status_failed" as const), + status_label: options?.pendingReportRun ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -1099,7 +1166,7 @@ describe("App, authenticated", () => { postBody: `

Quote attached.

Please confirm.

`, }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); @@ -1114,7 +1181,7 @@ describe("App, authenticated", () => { render(); - const listButton = await screen.findByRole("button", { name: "View post: Public post" }); + const listButton = await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." }); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/posts"), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-access-token" }) }), @@ -1147,7 +1214,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("이것은 요약입니다.")).toBeInTheDocument()); expect(screen.getByText("첫 번째 이벤트")).toBeInTheDocument(); @@ -1167,7 +1234,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("The seeded follow-up after the site visit.")).toBeInTheDocument(), ); @@ -1184,7 +1251,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByPlaceholderText(/what happened/i)).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/what happened/i), "What happened?"); @@ -1209,7 +1276,7 @@ describe("App, authenticated", () => { stubBackend({ chatUnavailable: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByPlaceholderText(/what happened/i)).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/what happened/i), "What happened?"); await userEvent.click(screen.getByRole("button", { name: /^ask$/i })); @@ -1235,7 +1302,7 @@ describe("App, authenticated", () => { stubBackend({ admin: true, chatUnavailable: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: /evaluate post/i })); await waitFor(() => @@ -1249,7 +1316,7 @@ describe("App, authenticated", () => { stubBackend({ admin: true, chatUnavailable: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: /extract keymen/i })); await waitFor(() => @@ -1263,7 +1330,7 @@ describe("App, authenticated", () => { stubBackend({ admin: true, chatUnavailable: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: /derive commitment/i })); await waitFor(() => @@ -1279,7 +1346,7 @@ describe("App, authenticated", () => { stubBackend({ admin: true, searchUnavailable: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: /verify against web search/i })); await waitFor(() => @@ -1293,7 +1360,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("Demo Group")).toBeInTheDocument()); expect(screen.getByRole("button", { name: "Affiliate org: Demo Corp" })).toBeInTheDocument(); @@ -1335,7 +1402,7 @@ describe("App, authenticated", () => { it("opens related Keyman nodes from an R&R person", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "R&R Keyman: Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); expect(screen.getByText("Related to Ada West").closest(".related-keymen")).toHaveTextContent( @@ -1346,7 +1413,7 @@ describe("App, authenticated", () => { it("opens related nodes from an R&R team", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "R&R team: 설계팀" })); await waitFor(() => expect(screen.getByText("Related to 설계팀")).toBeInTheDocument()); expect(screen.getByText("Related to 설계팀").closest(".related-keymen")).toHaveTextContent( @@ -1357,7 +1424,7 @@ describe("App, authenticated", () => { it("opens related nodes from a related team chip", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); await userEvent.click(screen.getByRole("button", { name: "Related nodes for 설계팀" })); @@ -1370,7 +1437,7 @@ describe("App, authenticated", () => { it("opens related nodes from a related corporate entity", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(screen.getByRole("button", { name: "Related nodes for Ada West" })); await waitFor(() => expect(screen.getByText("Related to Ada West")).toBeInTheDocument()); await userEvent.click(screen.getByRole("button", { name: "Related nodes for Demo Corp" })); @@ -1383,7 +1450,7 @@ describe("App, authenticated", () => { it("shows the VOC excerpt under its counterparty, not a detached list", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); const name = await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" }); const excerpt = screen.getByText( "Ada West at Demo Corp followed up with Priya Nair at Northridge Grid about the delayed shipment.", @@ -1403,7 +1470,7 @@ describe("App, authenticated", () => { it("opens related Keyman nodes from a VOC counterparty organization", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "VOC Keyman: Northridge Grid" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( @@ -1414,7 +1481,7 @@ describe("App, authenticated", () => { it("opens related Keyman nodes from an affiliate-tree person", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate Keyman: Priya Nair" })); await waitFor(() => expect(screen.getByText("Related to Priya Nair")).toBeInTheDocument()); expect(screen.getByText("Related to Priya Nair").closest(".related-keymen")).toHaveTextContent( @@ -1425,7 +1492,7 @@ describe("App, authenticated", () => { it("opens related nodes from a Keyman affiliation organization", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "Keyman affiliation: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( @@ -1436,7 +1503,7 @@ describe("App, authenticated", () => { it("opens related nodes from an affiliate-tree organization", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "Affiliate org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( @@ -1448,7 +1515,7 @@ describe("App, authenticated", () => { it("opens related nodes from a classified counterparty organization", async () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: "Counterparty org: Demo Corp" })); await waitFor(() => expect(screen.getByText("Related to Demo Corp")).toBeInTheDocument()); expect(screen.getByText("Related to Demo Corp").closest(".related-keymen")).toHaveTextContent( @@ -1460,7 +1527,7 @@ describe("App, authenticated", () => { it("links a verification badge only for http(s) evidence URLs", async () => { stubBackend({ verificationEvidenceUrl: "https://example.test/searxng?q=Northridge" }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); const badge = await screen.findByRole("link", { name: "VOC verification: Northridge Grid" }); expect(badge).toHaveAttribute("href", "https://example.test/searxng?q=Northridge"); }); @@ -1468,7 +1535,7 @@ describe("App, authenticated", () => { it("does not turn a javascript: evidence URL into a verification link", async () => { stubBackend({ verificationEvidenceUrl: "javascript:alert(1)" }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByLabelText("VOC verification: Northridge Grid")).toBeInTheDocument(), ); @@ -1480,7 +1547,7 @@ describe("App, authenticated", () => { const fetchMock = stubBackend({ admin: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByLabelText("VOC verification: Northridge Grid")).toHaveTextContent("Not yet checked"), ); @@ -1501,7 +1568,7 @@ describe("App, authenticated", () => { const fetchMock = stubBackend({ admin: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await userEvent.click(await screen.findByRole("button", { name: /extract keymen/i })); await waitFor(() => @@ -1516,7 +1583,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("No tickets yet.")).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/new ticket title/i), "Confirm delivery window"); @@ -1539,7 +1606,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("No tickets yet.")).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/new ticket title/i), "Ship the sample kit"); @@ -1554,7 +1621,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("No activity yet.")).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/new ticket title/i), "Confirm freight terms"); @@ -1586,7 +1653,7 @@ describe("App, authenticated", () => { stubBackend(); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("No tickets yet.")).toBeInTheDocument()); expect(screen.queryByRole("button", { name: /derive commitment/i })).not.toBeInTheDocument(); }); @@ -1595,7 +1662,7 @@ describe("App, authenticated", () => { stubBackend({ admin: true }); render(); - await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); + await userEvent.click(await screen.findByRole("button", { name: "View post: Public post. Voice of Customer. Public." })); await waitFor(() => expect(screen.getByText("No tickets yet.")).toBeInTheDocument()); await userEvent.click(screen.getByRole("button", { name: /derive commitment/i })); @@ -1622,14 +1689,14 @@ describe("App, authenticated", () => { render(); const calendarButton = await screen.findByRole("button", { - name: /open commitment for: public post/i, + name: "Open commitment for: Public post. Send Northridge Grid the revised quote. Open. due 2026-01-12", }); expect(calendarButton).toHaveTextContent("Send Northridge Grid the revised quote"); expect(calendarButton).toHaveTextContent("Public post"); expect(calendarButton).toHaveTextContent("Open"); expect(calendarButton).toHaveTextContent("due 2026-01-12"); const betaCalendar = screen.getByRole("button", { - name: /open commitment for: specification revision requested/i, + name: "Open commitment for: Specification revision requested. Send Westfield Power the revised specification. Open. due 2026-01-14", }); expect(betaCalendar).toHaveTextContent("Send Westfield Power the revised specification"); expect(betaCalendar).toHaveTextContent("Open"); @@ -1640,6 +1707,32 @@ describe("App, authenticated", () => { await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); }); + it("keeps calendar, report, and post-list facts in the accessible name", async () => { + stubBackend(); + render(); + + expect( + await screen.findByRole("button", { + name: "Open commitment for: Public post. Send Northridge Grid the revised quote. Open. due 2026-01-12", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Open report period 2026-W02. mean θ 0.00. shared metric. CAT: sales-lead I=0.70", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "Compare Corporate entity: Test Corp. mean θ 0.01. 8 posts", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "View post: Public post. Voice of Customer. Public.", + }), + ).toBeInTheDocument(); + }); + it("shows the seeded analysis run on the home page", async () => { stubBackend(); render(); @@ -1707,7 +1800,7 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }), ); expect( @@ -1725,10 +1818,10 @@ describe("App, authenticated", () => { await screen.findByRole("list", { name: "Analysis runs" }); const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp. Open this run to see why it failed, then retry reconstruction from a current snapshot.", }); const teppButton = screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }); expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", @@ -1738,6 +1831,18 @@ describe("App, authenticated", () => { "Open this run to see why it failed, then connect the measurement service and re-run.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(lineageButton); + expect( + await screen.findByRole("heading", { + name: "Lineage reconstruction · Failed · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getAllByText( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ).length, + ).toBeGreaterThanOrEqual(2); }); it("does not tell a failed period report to connect the measurement service", async () => { @@ -1745,7 +1850,7 @@ describe("App, authenticated", () => { render(); const reportButton = await screen.findByRole("button", { - name: "Open analysis run: Period report · Failed · Demo Corp", + name: "Open analysis run: Period report · Failed · Demo Corp. Open this run to see why it failed, then rebuild the period report from a current snapshot.", }); expect(reportButton).toHaveTextContent( "Open this run to see why it failed, then rebuild the period report from a current snapshot.", @@ -1761,21 +1866,85 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); - it("does not tell a pending TEPP run that it already measured", async () => { + it("does not tell a pending period report that reconstruction or measurement has started", async () => { + stubBackend({ pendingReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Pending · Demo Corp. Open this run to confirm which posts the period report will use. The report has not been built yet.", + }); + expect(reportButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(reportButton).not.toHaveTextContent("measurement"); + + await userEvent.click(reportButton); + expect( + screen.getAllByText( + "Open this run to confirm which posts the period report will use. The report has not been built yet.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/TEPP will measure/)).not.toBeInTheDocument(); + }); + + it("does not tell a pending TEPP run that reconstruction or measurement already finished", async () => { stubBackend({ pendingTeppRun: true }); render(); + const teppButton = await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp. Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.", + }); + expect(teppButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(teppButton).not.toHaveTextContent("measured"); + + await userEvent.click(teppButton); + expect( + screen.getAllByText( + "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect( + await screen.findByText( + "These posts are the cutoff corpus TEPP will measure. Measurement has not started — this is not a calibrated result.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/once this run finishes/)).not.toBeInTheDocument(); + }); + + it("does not tell a running TEPP run that it already measured", async () => { + stubBackend({ runningTeppRun: true }); + render(); + await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: TEPP measurement · Running · Demo Corp", }), ); expect( await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), ).toBeInTheDocument(); - expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Measurement has not started/)).not.toBeInTheDocument(); + }); + + it("does not tell a cancelled TEPP run that it produced a calibrated result", async () => { + stubBackend({ cancelledTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Cancelled · Demo Corp", + }), + ); + expect( + await screen.findByText( + "These posts are the cutoff corpus this TEPP run would have measured. The run was cancelled before a calibrated result.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { @@ -1805,7 +1974,7 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); expect( screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp", + name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp. Open this run to confirm which posts it will use. Reconstruction has not started yet.", }), ).toBeInTheDocument(); expect( @@ -1813,6 +1982,8 @@ describe("App, authenticated", () => { "Open this run to confirm which posts it will use. Reconstruction has not started yet.", ), ).toHaveLength(2); + expect(screen.queryByText(/Measurement has not started/)).not.toBeInTheDocument(); + expect(screen.queryByText(/The report has not been built/)).not.toBeInTheDocument(); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); @@ -1833,31 +2004,30 @@ describe("App, authenticated", () => { expect(screen.getByText(/TEST-PU-REPORT/)).toBeInTheDocument(); expect(screen.getAllByText("shared metric").length).toBeGreaterThan(0); expect(screen.getAllByText(/CAT: sales-lead I=0\.70/).length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: /open report period 2026-W03/i })).toHaveTextContent( - "vs 2026-W02: +0.92", - ); - expect(screen.queryByRole("button", { name: /rebuild report/i })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("θ 0.91"); - expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent( - "Send Northridge Grid the revised quote", - ); - expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("Open"); - expect(screen.getByRole("button", { name: /open report post: public post/i })).toHaveTextContent("due 2026-01-12"); expect( - screen.getByRole("button", { name: /open report post: specification revision requested/i }), - ).toHaveTextContent("Send Westfield Power the revised specification"); - expect( - screen.getByRole("button", { name: /open report post: specification revision requested/i }), - ).toHaveTextContent("Open"); + screen.getByRole("button", { + name: "Open report period 2026-W03. mean θ 0.92. vs 2026-W02: +0.92. CAT: sales-lead I=0.65", + }), + ).toHaveTextContent("vs 2026-W02: +0.92"); + expect(screen.queryByRole("button", { name: /rebuild report/i })).not.toBeInTheDocument(); + const reportPost = screen.getByRole("button", { + name: "Open report post: Public post. θ 0.91. Send Northridge Grid the revised quote. Open. due 2026-01-12", + }); + expect(reportPost).toHaveTextContent("θ 0.91"); + expect(reportPost).toHaveTextContent("Send Northridge Grid the revised quote"); + expect(reportPost).toHaveTextContent("Open"); + expect(reportPost).toHaveTextContent("due 2026-01-12"); + const specReportPost = screen.getByRole("button", { + name: "Open report post: Specification revision requested. θ 0.18. Send Westfield Power the revised specification. Open. due 2026-01-14", + }); + expect(specReportPost).toHaveTextContent("Send Westfield Power the revised specification"); + expect(specReportPost).toHaveTextContent("Open"); + expect(specReportPost).toHaveTextContent("due 2026-01-14"); expect( - screen.getByRole("button", { name: /open report post: specification revision requested/i }), - ).toHaveTextContent("due 2026-01-14"); - expect(screen.getByRole("button", { name: /open commitment for: public post/i })).toHaveTextContent( - "Send Northridge Grid the revised quote", - ); - expect(screen.getByRole("button", { name: /open commitment for: public post/i })).toHaveTextContent( - "due 2026-01-12", - ); + screen.getByRole("button", { + name: "Open commitment for: Public post. Send Northridge Grid the revised quote. Open. due 2026-01-12", + }), + ).toHaveTextContent("Send Northridge Grid the revised quote"); }); it("shows the grouping comparison strip and switches grouping on click", async () => { @@ -1865,10 +2035,16 @@ describe("App, authenticated", () => { render(); expect(await screen.findByLabelText("Grouping comparison")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /compare process_unit: demo report high/i })).toHaveTextContent( - "mean θ 0.81", + expect( + screen.getByRole("button", { + name: "Compare Process unit: Demo Report High. mean θ 0.81. 4 posts", + }), + ).toHaveTextContent("mean θ 0.81"); + await userEvent.click( + screen.getByRole("button", { + name: "Compare Thread group: A-100. mean θ 0.81. 4 posts", + }), ); - await userEvent.click(screen.getByRole("button", { name: /compare thread_group: a-100/i })); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining("/api/reports/thread_group/2026-W02"), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07088e9d4..264ab4886 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import { type ChatExchange, type Counterparty, type EvaluationResponse, + type GroupingComparisonRow, type IssueTicket, type LineageGraph, type Keyman, @@ -51,9 +52,11 @@ import { type PostDetail, type PeriodComparison, type PeriodReportIndex, + type PeriodReportSummary, type PeriodReports, type PostLineage, type PostSummary, + type ReportMember, type RelatedNode, type RelatedNodeType, type VocEvidence, @@ -89,6 +92,16 @@ function criterionShortLabel(itemCode: string): string { return CRITERION_SHORT_LABEL[itemCode] ?? itemCode; } +const REPORT_GROUPING_LABELS: Record = { + process_unit: "Process unit", + corporate_entity: "Corporate entity", + thread_group: "Thread group", +}; + +function reportGroupingLabel(kind: string): string { + return REPORT_GROUPING_LABELS[kind] ?? kind; +} + // This popup's layout follows the textual product brief (Korean summary, // key events, R&R, Event Lineage, Keyman, in-popup chat with a sliding // evidence panel) rather than the referenced Figma frame's actual pixel @@ -1466,7 +1479,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to confirm which posts the period report will use. The report has not been built yet."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to confirm its next step. The registered kind is not lineage, TEPP, or a period report."; } } case "analysis_status_failed": @@ -1479,7 +1493,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to see why it failed, then retry from a current snapshot."; } } case "analysis_status_running": @@ -1489,11 +1504,104 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return null; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "Open this run to confirm its current status before acting."; } } } +/** + * List-button accessible name (WCAG 2.2 SC 4.1.2 / AccName 1.1). + * + * `aria-label` replaces the button contents, so the next-action sentence + * must be in the name or a screen reader only hears the caption. + */ +function analysisRunAccessibleName(run: AnalysisRun): string { + const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); + return nextAction ? `Open analysis run: ${caption}. ${nextAction}` : `Open analysis run: ${caption}`; +} + +/** + * Calendar-button accessible name (WCAG 2.2 SC 4.1.2 / AccName 1.1). + * + * `aria-label` replaces the button contents, so the commitment, status, + * and due date must be in the name or a screen reader only hears the + * post title. + */ +function calendarCommitmentAccessibleName(entry: CalendarEntry): string { + const summary = entry.commitment_summary ?? entry.ticket_title; + const status = entry.ticket_status_label ?? entry.ticket_status_code; + const parts = [`Open commitment for: ${entry.post_title}`, summary, status]; + if (entry.due_date) { + parts.push(`due ${entry.due_date}`); + } + return parts.filter((part) => Boolean(part)).join(". "); +} + +/** + * Period-index button accessible name. The visible mean θ, FIPC delta, + * and CAT item stay in the name so the operator can pick a period + * without seeing the badges. + */ +function reportPeriodAccessibleName(row: PeriodReportSummary): string { + const parts = [`Open report period ${row.period_code}`, `mean θ ${row.mean_theta.toFixed(2)}`]; + if (row.link_method === "fipc" && row.anchor_period_code && row.delta_mean_theta != null) { + const signed = `${row.delta_mean_theta >= 0 ? "+" : ""}${row.delta_mean_theta.toFixed(2)}`; + parts.push(`vs ${row.anchor_period_code}: ${signed}`); + } else if (row.link_method === "fipc") { + parts.push("shared metric"); + } else { + parts.push("reference"); + } + if (row.selected_item_code && row.selected_item_information != null) { + parts.push( + `CAT: ${criterionShortLabel(row.selected_item_code)} I=${row.selected_item_information.toFixed(2)}`, + ); + } + return parts.join(". "); +} + +/** + * Report-member button accessible name. θ, ticket, and due date are the + * next facts the operator uses to open the live post. + */ +function reportPostAccessibleName(member: ReportMember): string { + const parts = [`Open report post: ${member.post_title}`, `θ ${member.theta_eap.toFixed(2)}`]; + if (member.ticket_title) { + parts.push(member.ticket_title); + } + const status = member.ticket_status_label ?? member.ticket_status_code; + if (status) { + parts.push(status); + } + if (member.ticket_due_date) { + parts.push(`due ${member.ticket_due_date}`); + } + return parts.join(". "); +} + +/** + * Grouping-comparison button accessible name. The visible mean θ and + * post count must stay in the name. + */ +function reportCompareAccessibleName(row: GroupingComparisonRow): string { + return ( + `Compare ${reportGroupingLabel(row.grouping_kind)}: ${row.grouping_label}. ` + + `mean θ ${row.mean_theta.toFixed(2)}. ${row.post_count} posts` + ); +} + +/** + * Home post-list accessible name. VOC and visibility labels are the + * next facts the operator uses to decide which post to open. + */ +function postListAccessibleName(post: PostSummary): string { + const voc = post.voc_type_label ?? post.voc_type_code; + const visibility = post.visibility_label ?? post.visibility_code; + return `View post: ${post.post_title}. ${voc}. ${visibility}.`; +} + /** * Empty-corpus copy that tells the operator what to do next. */ @@ -1516,7 +1624,11 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { ); default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); } } } @@ -1538,6 +1650,10 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { case "analysis_status_succeeded": return "These posts are the cutoff corpus this TEPP run measured."; case "analysis_status_pending": + return ( + "These posts are the cutoff corpus TEPP will measure. Measurement has " + + "not started — this is not a calibrated result." + ); case "analysis_status_running": return "These posts are the cutoff corpus TEPP will measure once this run finishes."; case "analysis_status_cancelled": @@ -1549,7 +1665,8 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus attached to this TEPP run."; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "These posts are the cutoff corpus attached to this TEPP run."; } } } @@ -1697,7 +1814,7 @@ function AnalysisRunsPanel({