diff --git a/platform/lib/queries/temporal.ts b/platform/lib/queries/temporal.ts
index 4e7582b..d6a128a 100644
--- a/platform/lib/queries/temporal.ts
+++ b/platform/lib/queries/temporal.ts
@@ -279,16 +279,18 @@ export async function getOrgReposSummary(
if (!repos || repos.length === 0) return [];
// Query 2: pre-aggregated summary, one row per repo (see doc comment).
- // The column list is built at runtime, so supabase-js can't infer the row.
- const readSummaries = async (columns: string) =>
- (await supabase
+ const readSummaries = async (columns: string) => {
+ const result = await supabase
.from("repo_metric_summaries")
.select(columns)
.eq("organization_id", organizationId)
- .eq("window_days", windowDays)) as unknown as {
+ .eq("window_days", windowDays);
+ // The column list is built at runtime, so supabase-js can't infer the row.
+ return result as unknown as {
data: SummaryRow[] | null;
error: PostgrestError | null;
};
+ };
let summaries: SummaryRow[] | null;
let summariesError: PostgrestError | null;
diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts
index 3eb23ea..2b89e53 100644
--- a/platform/lib/translations.ts
+++ b/platform/lib/translations.ts
@@ -627,10 +627,10 @@ export const translations = {
basic: "PR list",
enrichment: "commit enrichment",
reviews: "reviews",
- fetch: "PR read",
+ fetch: "entire read",
},
incompleteTooltip:
- "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails, and flow, review and in-PR metrics may be missing or skewed.",
+ "This run's PR read failed at: {steps}. Merge strategy isn't classified when enrichment fails for merged PRs, and PR lifecycle (counts, time to merge, cycle time), flow, review, in-PR and open-PR metrics may be missing or skewed.",
},
dora: {
title: "DORA",
@@ -2201,10 +2201,10 @@ export const translations = {
basic: "listagem de PRs",
enrichment: "enriquecimento de commits",
reviews: "reviews",
- fetch: "leitura de PRs",
+ fetch: "leitura inteira",
},
incompleteTooltip:
- "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento falha, e métricas de flow, de review e de commits em PR podem faltar ou sair distorcidas.",
+ "A leitura de PRs desta execução falhou em: {steps}. A estratégia de merge não é classificada quando o enriquecimento dos PRs mergeados falha, e métricas de ciclo de vida de PR (contagens, tempo até o merge, cycle time), de flow, de review, de commits em PR e de PRs abertos podem faltar ou sair distorcidas.",
},
dora: {
title: "DORA",
diff --git a/platform/src/app/[tenant]/compare/compare-view.tsx b/platform/src/app/[tenant]/compare/compare-view.tsx
index dbd3ab6..c3ef5f4 100644
--- a/platform/src/app/[tenant]/compare/compare-view.tsx
+++ b/platform/src/app/[tenant]/compare/compare-view.tsx
@@ -219,20 +219,36 @@ function MergeStrategyCell({
}
/**
- * Amber dot next to a repo whose latest run read PR data only partially. The
- * dot is decorative (hover title for sighted users); the step list is also in
- * the DOM as screen-reader text, and a legend under the list says what the dot
- * means, so nothing hinges on colour or on a tooltip.
+ * The failed PR-read steps of a repo whose latest run read PR data only
+ * partially, on a line of their own under the repo name in both layouts: an
+ * amber dot (the legend under the list says what it means, so nothing hinges
+ * on colour) and the steps as text, for touch and keyboard users who get no
+ * hover. Amber 600 on light and 500 on dark keep the dot above 3:1 against
+ * the card (WCAG 1.4.11).
+ *
+ * The dot and the visible steps are aria-hidden, with the full sentence as
+ * their hover title; screen readers get that sentence once, as sr-only text.
*/
-function PrDataMarker({ repo, label }: { repo: RepoSummary; label: string }) {
+function PrDataSteps({
+ repo,
+ label,
+ steps,
+}: {
+ repo: RepoSummary;
+ label: string;
+ steps: string;
+}) {
if (repo.pr_degraded_steps.length === 0) return null;
return (
-
+
+ className="flex min-w-0 items-center gap-1.5"
+ >
+
+ {steps}
+
{label}
);
@@ -436,7 +452,11 @@ export function CompareView({ repos }: CompareViewProps) {
{repo.name}
-
+
|
-
-
- #{i + 1}
-
-
- {repo.name}
-
-
+
+
+
+ #{i + 1}
+
+
+ {repo.name}
+
+
+
{t("repos.detail.prData.incomplete")}
diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx
index e104e21..6cc7666 100644
--- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx
+++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx
@@ -255,17 +255,22 @@ export default async function RepoDetailPage({
))}
{/* In the header, not in the flow or merge panels: those can
- disappear when the read degrades, which would hide the cause. */}
+ disappear when the read degrades, which would hide the cause.
+ The failed steps are text next to the badge, for touch and
+ keyboard users too; no title repeating them, which screen
+ readers may read as a description on top of the text. */}
{prDegraded.length > 0 && (
-
- {t("repos.detail.prData.incomplete")}
-
+
+
+ {t("repos.detail.prData.incomplete")}
+
+
+ {formatPrSteps(prDegraded, t)}
+
+
)}
{repo.remote_url && (
diff --git a/platform/tests/compare-pr-data-marker.test.tsx b/platform/tests/compare-pr-data-marker.test.tsx
new file mode 100644
index 0000000..75dea81
--- /dev/null
+++ b/platform/tests/compare-pr-data-marker.test.tsx
@@ -0,0 +1,105 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import { translations } from "@/lib/translations";
+import type { RepoSummary } from "@/types/temporal";
+
+// The real hook reads the session; the view only needs `t()` over en-US.
+vi.mock("@/hooks/useTranslation", () => ({
+ useTranslation: () => ({
+ t: (path: string, params?: Record
) => {
+ let value: unknown = translations["en-US"];
+ for (const key of path.split(".")) {
+ value = (value as Record)?.[key];
+ }
+ let text = String(value);
+ for (const [k, v] of Object.entries(params ?? {})) {
+ text = text.replace(`{${k}}`, String(v));
+ }
+ return text;
+ },
+ }),
+}));
+
+function repo(
+ id: string,
+ name: string,
+ steps: RepoSummary["pr_degraded_steps"],
+): RepoSummary {
+ return {
+ id,
+ name,
+ remote_url: null,
+ last_run_at: null,
+ runs_count: 1,
+ stabilization_ratio: 0.9,
+ revert_rate: 0,
+ churn_events: 1,
+ commits_total: 10,
+ ai_detection_coverage_pct: null,
+ pr_merged_count: 1,
+ pr_single_pass_rate: null,
+ fix_latency_median_hours: null,
+ cascade_rate: null,
+ merge_strategy: null,
+ commit_metrics_reliable: null,
+ pr_degraded_steps: steps,
+ stabilization_delta: null,
+ health: "healthy",
+ sparkline: [],
+ };
+}
+
+async function render(): Promise {
+ const { CompareView } = await import("@/app/[tenant]/compare/compare-view");
+ return renderToStaticMarkup(
+ ,
+ );
+}
+
+const count = (text: string, part: string) => text.split(part).length - 1;
+
+describe("CompareView PR-data marker", () => {
+ // Touch and keyboard users get no hover title, and sighted keyboard users
+ // on the desktop table neither: both layouts show the steps as text.
+ it("shows the failed steps as text in the table and on the card", async () => {
+ const html = await render();
+
+ expect(count(html, "PR list, reviews")).toBe(2);
+ });
+
+ // Each layout reads the full sentence once: the visible steps (and the
+ // dot) are aria-hidden, and only the sr-only sentence is announced. The
+ // table and the card are never shown at once, so a reader meets one.
+ it("announces the full sentence once per layout", async () => {
+ const html = await render();
+
+ expect(count(html, 'This run')).toBe(2);
+ expect(html).not.toContain("PR data incomplete: ");
+ });
+
+ // On the card the name truncates: the steps get a line of their own so a
+ // long list cannot squeeze it.
+ it("keeps the steps off the card's name row", async () => {
+ const html = await render();
+ const rowStart = html.indexOf('items-baseline gap-2">');
+ const nameRow = html.slice(rowStart, html.indexOf(" ", rowStart));
+
+ expect(nameRow).toContain("widgets");
+ expect(nameRow).not.toContain("PR list");
+ });
+
+ // Amber 500 is ~2.1:1 on the light card, below WCAG 1.4.11's 3:1.
+ it("uses an amber dot that keeps 3:1 on light and dark", async () => {
+ const html = await render();
+
+ expect(html).toContain("bg-amber-600 dark:bg-amber-500");
+ expect(html).not.toMatch(/(? {
expect(prData.incomplete).toBeTruthy();
expect(prData.incompleteTooltip).toContain("{steps}");
});
+
+ // Enrichment failing for open or closed PRs leaves the merge strategy
+ // classified; only merged PRs feed it.
+ it("ties the unclassified merge strategy to merged PRs", () => {
+ expect(
+ translations["en-US"].repos.detail.prData.incompleteTooltip,
+ ).toContain("when enrichment fails for merged PRs");
+ expect(
+ translations["pt-BR"].repos.detail.prData.incompleteTooltip,
+ ).toContain("quando o enriquecimento dos PRs mergeados falha");
+ });
+
+ // A failed open list, or a failed pass for open PRs, omits open-PR fields
+ // too: the tooltip names them, as the engine's report caveat does.
+ it("names open-PR metrics among what may be missing", () => {
+ expect(
+ translations["en-US"].repos.detail.prData.incompleteTooltip,
+ ).toContain("flow, review, in-PR and open-PR metrics may be missing");
+ expect(
+ translations["pt-BR"].repos.detail.prData.incompleteTooltip,
+ ).toContain("de commits em PR e de PRs abertos podem faltar");
+ });
+
+ // A failed merged list omits the PR lifecycle fields, as the engine's
+ // report caveat says.
+ it("names PR lifecycle metrics among what may be missing", () => {
+ expect(
+ translations["en-US"].repos.detail.prData.incompleteTooltip,
+ ).toContain(
+ "PR lifecycle (counts, time to merge, cycle time), flow, review, in-PR and open-PR metrics",
+ );
+ expect(
+ translations["pt-BR"].repos.detail.prData.incompleteTooltip,
+ ).toContain(
+ "métricas de ciclo de vida de PR (contagens, tempo até o merge, cycle time), de flow",
+ );
+ });
});
describe("prDegradedSteps duplicates", () => {
@@ -67,10 +104,10 @@ describe("formatPrSteps", () => {
it("lists localized step labels, comma separated", () => {
const steps = ["basic", "enrichment", "reviews", "fetch"] as const;
expect(formatPrSteps(steps, translator("en-US"))).toBe(
- "PR list, commit enrichment, reviews, PR read",
+ "PR list, commit enrichment, reviews, entire read",
);
expect(formatPrSteps(["basic", "fetch"], translator("pt-BR"))).toBe(
- "listagem de PRs, leitura de PRs",
+ "listagem de PRs, leitura inteira",
);
});