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 ( - + ); @@ -436,7 +452,11 @@ export function CompareView({ repos }: CompareViewProps) { {repo.name} - +
-
- - #{i + 1} - - - {repo.name} - - +
+
+ + #{i + 1} + + + {repo.name} + +
+
{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, '
", 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", ); });