"use client"; import Link from "next/link"; import { useMemo } from "react"; import { EmptyState, SkeletonBlock } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { fmtInt } from "@/lib/format"; import type { DataGaps } from "@/lib/admin-api"; import { ErrorNote, Panel, RefreshButton, Stat } from "./primitives"; import { AdminPageHeader } from "./shell"; import { useAdminQuery } from "./use-admin-query"; /* ------------------------------------------------------------------------------------------ /admin/data-gaps — what we do not know, ranked for enrichment. Counters come from the worker (GET /data-gaps proxied by the API); a 502 means the maintenance worker is unreachable. ------------------------------------------------------------------------------------------ */ const GROUPS: Array<{ key: string; label: string; prefix: RegExp; description: string }> = [ { key: "facilities", label: "Facilities", prefix: /^facilities_/, description: "Live facility records (merged duplicates excluded)." }, { key: "projects", label: "Projects", prefix: /^projects_/, description: "Live project records (hidden and merged excluded)." }, { key: "operators", label: "Operators", prefix: /^operators_/, description: "Operator registry." }, { key: "other", label: "Pipeline & other entities", prefix: /.*/, description: "Reconciliation queue, quality flags, claims, cloud regions, IXPs." }, ]; /** Where a gap can be worked on — only links that are guaranteed to exist. */ const LINKS: Record = { pending_matches: "/admin/matches", open_quality_flags: "/admin/quality", unscoped_claims: "/admin/quality", claims_in_review: "/admin/quality", projects_without_facility_link: "/admin/projects", projects_without_coordinates: "/admin/projects", projects_without_country: "/admin/projects", projects_without_operator: "/admin/projects", projects_without_capacity: "/admin/projects", projects_without_expected_opening: "/admin/projects", projects_without_source: "/admin/projects", facilities_without_country: "/admin/quality?code=facility_no_country", }; function humanize(key: string): string { const s = key.replace(/^(facilities|projects|operators)_/, "").replace(/_/g, " "); return s.charAt(0).toUpperCase() + s.slice(1); } export function DataGapsView() { const q = useAdminQuery("data-gaps", { refreshMs: 300_000, timeoutMs: 30_000 }); const source = typeof q.meta?.source === "string" ? q.meta.source : null; const groups = useMemo(() => { const entries = Object.entries(q.data ?? {}).filter(([, v]) => Number.isFinite(v)); const used = new Set(); return GROUPS.map((g) => { const items = entries.filter(([k]) => !used.has(k) && g.prefix.test(k)); for (const [k] of items) used.add(k); items.sort((a, b) => b[1] - a[1]); return { ...g, items, total: items.reduce((a, [, v]) => a + v, 0) }; }).filter((g) => g.items.length); }, [q.data]); const max = useMemo(() => Math.max(1, ...Object.values(q.data ?? {})), [q.data]); const unreachable = q.error && /worker unreachable|502|fetch failed/i.test(q.error); return (
void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} /> {q.error && !q.data && ( void q.refresh()} className="mb-3" /> )} {q.loading && !q.data ? (
) : !q.data ? ( !q.error && ) : groups.length === 0 ? ( ) : (
{groups.map((g) => ( {fmtInt(g.total)} total} padded={false}>
    {g.items.map(([k, v]) => { const href = LINKS[k]; const inner = ( <>
    {humanize(k)} {fmtInt(v)}
    0.5 ? "bg-warn" : "bg-ink-3")} style={{ width: `${Math.max(v > 0 ? 1.5 : 0, (v / max) * 100)}%` }} />
    {k} {href && work on it →}
    ); return (
  • {href ? ( {inner} ) : (
    {inner}
    )}
  • ); })}
))}
)} {q.data && (
v === max)?.[0]?.replace(/_/g, " ")} /> v === 0).length)} tone="ok" />
)}

A gap is a missing published value, not a wrong one: the public site shows “—” for each of them. Enrichment adds sources; it never fills a gap with an estimate.

); }