diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts index 33a7ae1..393d37c 100644 --- a/platform/lib/queries/org-summary.ts +++ b/platform/lib/queries/org-summary.ts @@ -1088,6 +1088,18 @@ function diff(current: number | null, previous: number | null): number | null { // computeHyperEngineers — aggregate across repos, deduplicate by name // --------------------------------------------------------------------------- +/** + * The "hyper engineer" threshold, shared so the org-wide aggregate here and + * the repo-detail page's per-author badge never drift apart by having the + * same comparison hand-copied in two places. + */ +export function isHyperEngineer(author: { + high_velocity_weeks: number; + ai_commit_pct: number; +}): boolean { + return author.high_velocity_weeks > 0 || author.ai_commit_pct >= 80; +} + export function computeHyperEngineers( payloads: Map, userMap: Map, @@ -1110,8 +1122,7 @@ export function computeHyperEngineers( for (const a of av.authors) { const key = a.name.toLowerCase(); - const isHyper = a.high_velocity_weeks > 0 || a.ai_commit_pct >= 80; - if (!isHyper) continue; + if (!isHyperEngineer(a)) continue; const existing = authors.get(key) ?? { name: a.name, diff --git a/platform/lib/tenant.ts b/platform/lib/tenant.ts index f73ebb7..b93e37a 100644 --- a/platform/lib/tenant.ts +++ b/platform/lib/tenant.ts @@ -1,3 +1,5 @@ +import { cache } from "react"; + import { headers } from "next/headers"; import { debugDatabase, logError } from "./debug"; @@ -68,12 +70,24 @@ export async function getTenantFromRequest(): Promise { } /** - * Check if user has access to tenant + * Check if user has access to tenant. + * + * Wrapped in React's `cache()` so calling this again with the same + * (tenant, userId) elsewhere in the same request — e.g. a page that needs + * the caller's role for a permission check — dedupes to the layout's + * existing call instead of re-running the same two queries. Also returns + * the resolved org id/name so a page needing "org + role" never has to + * hand-roll its own lookup of data the layout already fetched. */ -export async function checkTenantAccess( +export const checkTenantAccess = cache(async function checkTenantAccess( tenant: string, userId: string, -): Promise<{ hasAccess: boolean; role?: string }> { +): Promise<{ + hasAccess: boolean; + role?: string; + orgId?: string; + orgName?: string; +}> { try { debugDatabase("Checking tenant access", { tenant, userId }); @@ -143,9 +157,11 @@ export async function checkTenantAccess( return { hasAccess: true, role: membership.role, + orgId: org.id, + orgName: org.name, }; } catch (error) { logError(error, "checkTenantAccess"); return { hasAccess: false }; } -} +}); diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 772021f..a969fc6 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -471,6 +471,8 @@ export const translations = { tightCouplingTitle: "Tight coupling", tightCouplingReason: "{fileA} and {fileB} change together {rate}% of the time ({count} joint changes). Decoupling would reduce rework cost across the area.", + tightCouplingLowSample: + "Based on only {count} joint changes — treat as a hypothesis, not a confirmed pattern.", fixMagnetTitle: "{origin} attracts fixes", fixMagnetReason: "Commits from {origin} represent {codeShare}% of changes but {fixShare}% of fixes ({disp}× the baseline, {count} fixes in window). Review patterns may need adjustment for this origin.", @@ -492,10 +494,13 @@ export const translations = { detected: "AI adoption detected on {date} ({count} AI commits)", hypothesisNote: "Deltas compare pre-adoption and post-adoption windows. Correlation, not causation — other changes may have overlapped.", + // No "insufficient" entry: AdoptionTimelineCard returns its own + // dedicated empty state for that confidence level before ever + // reaching the badge that reads this key (see adoption.insufficient + // below for that copy) — a badge label here would be dead code. confidence: { clear: "Clear", sparse: "Sparse", - insufficient: "Insufficient", }, columns: { metric: "Metric", @@ -575,7 +580,9 @@ export const translations = { }, repos: { title: "Repositories", - subtitle: "{count} repositories in {org}", + subtitle: "{count} {noun} in {org}", + repositorySingular: "repository", + repositoryPlural: "repositories", deleteButton: "Delete repository", deleteDialog: { title: "Delete Repository", @@ -610,6 +617,8 @@ export const translations = { "Deployment metrics scoped to this repository over the last {days} days — live from Datadog as of now. The rest of this page reflects the last analysis run, which may be older.", lowSample: "Based on only {actual} evaluated deploys (below the {threshold} this page treats as a stable read) — treat these four as directional, not precise.", + empty: + "No deployment data in this window — either no Datadog integration is connected, or this repository had zero deploys in the selected period.", incidentDisclaimer: "MTTR by incident isn't shown per-repo — Datadog failure events don't carry repository attribution, so any per-repo number would be a misleading copy of the org-wide one. See the dashboard for the incident-level view.", }, @@ -1878,6 +1887,8 @@ export const translations = { tightCouplingTitle: "Acoplamento alto", tightCouplingReason: "{fileA} e {fileB} mudam juntos {rate}% das vezes ({count} mudanças conjuntas). Desacoplar reduziria o custo de retrabalho na área.", + tightCouplingLowSample: + "Baseado em apenas {count} mudanças conjuntas — trate como hipótese, não como padrão confirmado.", fixMagnetTitle: "{origin} atrai fixes", fixMagnetReason: "Commits de {origin} representam {codeShare}% das mudanças mas {fixShare}% dos fixes ({disp}× o baseline, {count} fixes na janela). Os padrões de review podem precisar de ajuste para essa origem.", @@ -1903,7 +1914,6 @@ export const translations = { confidence: { clear: "Claro", sparse: "Esparso", - insufficient: "Insuficiente", }, columns: { metric: "Métrica", @@ -1983,7 +1993,9 @@ export const translations = { }, repos: { title: "Repositórios", - subtitle: "{count} repositórios em {org}", + subtitle: "{count} {noun} em {org}", + repositorySingular: "repositório", + repositoryPlural: "repositórios", deleteButton: "Excluir repositório", deleteDialog: { title: "Excluir Repositório", @@ -2019,6 +2031,8 @@ export const translations = { "Métricas de deploy deste repositório nos últimos {days} dias — direto do Datadog, a partir de agora. O resto desta página reflete a última análise, que pode ser mais antiga.", lowSample: "Baseado em apenas {actual} deploys avaliados (abaixo dos {threshold} que esta página considera uma leitura estável) — trate esses quatro números como direcionais, não precisos.", + empty: + "Sem dados de deploy nessa janela — ou não há integração com o Datadog conectada, ou esse repositório teve zero deploys no período selecionado.", incidentDisclaimer: "MTTR por incidente não aparece per-repo — os eventos de falha do Datadog não carregam atribuição de repositório, então qualquer número per-repo seria uma cópia enganosa do org-wide. Veja a visão de incidentes no dashboard.", }, diff --git a/platform/src/app/[tenant]/repos/[repoName]/charts.tsx b/platform/src/app/[tenant]/repos/[repoName]/charts.tsx index f692823..03f4600 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/charts.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/charts.tsx @@ -96,10 +96,14 @@ function DistributionBar({ labels: Record; colors: Record; }) { - const total = Object.values(data).reduce((a, b) => a + b, 0); - if (total === 0) return null; - const order = Object.keys(labels); + // Sum only over the keys this bar actually renders, matching + // FlowEfficiencyCard's totalHours below — if the engine ever adds an + // intent/origin value with no matching label here, that bucket's count + // would otherwise inflate `total` while never appearing in the bar, + // making the rendered segments silently sum to less than 100%. + const total = order.reduce((sum, key) => sum + (data[key] ?? 0), 0); + if (total === 0) return null; return (
diff --git a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx index 665ec5e..14e969d 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/dora-card.tsx @@ -4,18 +4,54 @@ import { Activity } from "lucide-react"; import { MetricCard } from "@/components/charts/MetricCard"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; import { MIN_EVALUATED_FOR_KPIS } from "@/lib/queries/dora"; import type { RepoDORA } from "@/types/org-summary"; interface Props { - data: RepoDORA; + data: RepoDORA | null; + /** Needed for the empty state's subtitle — `data` has no windowDays when null. */ + windowDays: number; } -export function DORARepoCard({ data }: Props) { +export function DORARepoCard({ data, windowDays }: Props) { const { t } = useTranslation(); + if (!data) { + // Investment Hotspots and Adoption Timeline both always render an + // explanatory card when they have nothing to show — this section used + // to just vanish instead, leaving no indication of why (no Datadog + // integration? zero deploys in the window? disabled?). + return ( + + + + {t("repos.detail.dora.title")} + + + {t("dashboard.dora.sourceBadge")} + + + + {t("repos.detail.dora.subtitle", { days: windowDays })} + + + +

+ {t("repos.detail.dora.empty")} +

+
+
+ ); + } + const evaluatedDeploys = data.deploymentsTotal - data.deploymentsPendingEvaluation; const lowSample = evaluatedDeploys < MIN_EVALUATED_FOR_KPIS; diff --git a/platform/src/app/[tenant]/repos/[repoName]/investment-hotspots.tsx b/platform/src/app/[tenant]/repos/[repoName]/investment-hotspots.tsx index b7370ed..8e23630 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/investment-hotspots.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/investment-hotspots.tsx @@ -77,6 +77,13 @@ function HotspotRow({ hotspot }: { hotspot: InvestmentHotspot }) { } if (hotspot.kind === "tight_coupling") { + // The engine's own floor for even surfacing a coupling hotspot is 3 + // joint changes (COUPLING_MIN_OCCURRENCES in invest-here.ts) — right at + // that floor, "90% coupled" is 3-for-3, not a real trend. Below this + // (still low but arbitrary) bar, say so instead of badging it exactly + // like a hotspot backed by dozens of occurrences. + const isLowSample = hotspot.coOccurrences < 5; + return (
@@ -95,6 +102,13 @@ function HotspotRow({ hotspot }: { hotspot: InvestmentHotspot }) { count: hotspot.coOccurrences, })}

+ {isLowSample && ( +

+ {t("investHere.tightCouplingLowSample", { + count: hotspot.coOccurrences, + })} +

+ )}
); diff --git a/platform/src/app/[tenant]/repos/[repoName]/page.tsx b/platform/src/app/[tenant]/repos/[repoName]/page.tsx index 43c9177..322ae28 100644 --- a/platform/src/app/[tenant]/repos/[repoName]/page.tsx +++ b/platform/src/app/[tenant]/repos/[repoName]/page.tsx @@ -16,6 +16,7 @@ import { authOptions } from "@/lib/auth"; import { extractAdoptionSummary } from "@/lib/queries/adoption-timeline"; import { computeRepoDORA } from "@/lib/queries/dora"; import { computeInvestmentHotspots } from "@/lib/queries/invest-here"; +import { isHyperEngineer } from "@/lib/queries/org-summary"; import { getAvailableWindowDays, resolveWindowDays, @@ -189,7 +190,7 @@ export default async function RepoDetailPage({ }) ?? {}; const hyperEngineers = new Set( (authorVelocity.authors ?? []) - .filter((a) => a.high_velocity_weeks > 0 || a.ai_commit_pct >= 80) + .filter((a) => isHyperEngineer(a)) .map((a) => a.name), ); @@ -297,6 +298,7 @@ export default async function RepoDetailPage({ : "\u2014" } delta={revertDelta} + deltaDecimals={1} invertDelta // Same defaulting issue as stabilization above, but toward 0.0%. hint={ @@ -327,7 +329,7 @@ export default async function RepoDetailPage({ aiImpact={aiImpact} /> - {repoDORA && } + diff --git a/platform/src/app/[tenant]/repos/page.tsx b/platform/src/app/[tenant]/repos/page.tsx index d057edb..0fa6142 100644 --- a/platform/src/app/[tenant]/repos/page.tsx +++ b/platform/src/app/[tenant]/repos/page.tsx @@ -14,6 +14,7 @@ import { } from "@/lib/queries/temporal"; import { getServerTranslation } from "@/lib/server-translation"; import { supabaseAdmin } from "@/lib/supabase"; +import { checkTenantAccess } from "@/lib/tenant"; export default async function ReposPage({ params, @@ -29,28 +30,22 @@ export default async function ReposPage({ const { window: windowParam } = await searchParams; const { t } = await getServerTranslation(); - const { data: org } = await supabaseAdmin - .from("organizations") - .select("id, name") - .eq("slug", tenant) - .single(); - - if (!org) notFound(); - - const { data: membership } = await supabaseAdmin - .from("organization_members") - .select("role") - .eq("user_id", session.user.id) - .eq("organization_id", org.id) - .single(); + // Deduped (React cache()) against the [tenant] layout's own call for the + // same (tenant, userId) — this used to re-run its own org-by-slug and + // membership-by-user queries here, duplicating exactly what the layout + // had already fetched to decide whether to render this page at all. + const { hasAccess, role, orgId, orgName } = await checkTenantAccess( + tenant, + session.user.id, + ); + if (!hasAccess || !orgId || !orgName) notFound(); - const role = membership?.role as "owner" | "admin" | "member" | undefined; const canDelete = role === "owner" || role === "admin"; // Analysis window (issue #80): resolve to a window the org actually has // data for, instead of defaulting to 90d and showing every repo as "0 // runs" when the org ingests under a different window. - const availableWindows = await getAvailableWindowDays(supabaseAdmin, org.id); + const availableWindows = await getAvailableWindowDays(supabaseAdmin, orgId); const windowDays = resolveWindowDays( parseWindowParam(windowParam), availableWindows, @@ -58,7 +53,7 @@ export default async function ReposPage({ const repoSummaries = await getOrgReposSummary( supabaseAdmin, - org.id, + orgId, windowDays, ); @@ -70,7 +65,11 @@ export default async function ReposPage({

{t("repos.subtitle", { count: repoSummaries.length, - org: org.name, + org: orgName, + noun: + repoSummaries.length === 1 + ? t("repos.repositorySingular") + : t("repos.repositoryPlural"), })}

@@ -80,7 +79,7 @@ export default async function ReposPage({ diff --git a/platform/src/components/charts/MetricCard.tsx b/platform/src/components/charts/MetricCard.tsx index 7e2b430..eb921b5 100644 --- a/platform/src/components/charts/MetricCard.tsx +++ b/platform/src/components/charts/MetricCard.tsx @@ -8,6 +8,13 @@ interface MetricCardProps { value: string; delta?: number | null; deltaFormat?: "pp" | "abs" | "pct"; + /** + * Decimal places for a "pp"/"pct" delta. Defaults to 0. Set this to match + * the value's own precision — e.g. a value shown as "2.3%" pairing with a + * "+0pp" delta (rounded from +0.6) reads as a mismatch even though both + * numbers are correct. + */ + deltaDecimals?: number; invertDelta?: boolean; // true = negative delta is good (e.g. revert rate) /** * true = this metric is raw activity volume (e.g. commit count), not an @@ -24,6 +31,7 @@ export function MetricCard({ value, delta, deltaFormat = "pp", + deltaDecimals = 0, invertDelta = false, neutral = false, hint, @@ -39,9 +47,9 @@ export function MetricCard({ const sign = d > 0 ? "+" : ""; switch (deltaFormat) { case "pp": - return `${sign}${(abs * 100).toFixed(0)}pp`; + return `${sign}${(abs * 100).toFixed(deltaDecimals)}pp`; case "pct": - return `${sign}${(abs * 100).toFixed(0)}%`; + return `${sign}${(abs * 100).toFixed(deltaDecimals)}%`; case "abs": return `${sign}${abs.toFixed(0)}`; } diff --git a/platform/tests/org-summary.test.ts b/platform/tests/org-summary.test.ts index b5b04f6..f88a960 100644 --- a/platform/tests/org-summary.test.ts +++ b/platform/tests/org-summary.test.ts @@ -6,6 +6,7 @@ import { computeOrgPulse, computePreviousTotals, computePRHealth, + isHyperEngineer, } from "@/lib/queries/org-summary"; import type { ReportMetrics } from "@/types/metrics"; import type { RepoSummary } from "@/types/temporal"; @@ -320,3 +321,26 @@ describe("computePRHealth — previous-period deltas", () => { expect(out!.medianReviewRoundsDelta).toBeNull(); }); }); + +describe("isHyperEngineer — shared threshold", () => { + it("qualifies on high_velocity_weeks alone", () => { + expect(isHyperEngineer({ high_velocity_weeks: 1, ai_commit_pct: 0 })).toBe( + true, + ); + }); + + it("qualifies on ai_commit_pct >= 80 alone", () => { + expect(isHyperEngineer({ high_velocity_weeks: 0, ai_commit_pct: 80 })).toBe( + true, + ); + expect(isHyperEngineer({ high_velocity_weeks: 0, ai_commit_pct: 79 })).toBe( + false, + ); + }); + + it("does not qualify when neither condition holds", () => { + expect(isHyperEngineer({ high_velocity_weeks: 0, ai_commit_pct: 0 })).toBe( + false, + ); + }); +});