Skip to content

Commit fe7fd22

Browse files
JessepriaseJess
andauthored
feat: data quality dashboard with completeness metrics and reconciliation UI (#918) (#1166)
New components in components/data-quality/: types.ts — LedgerGap, DataQualityMetrics, ReconciliationJob, EventCountPoint, GapFillPreviewItem, AnomalyAlert, DateRange DataQualityScorecard — completeness % per contract with colour-coded progress bar (green>=99 / yellow>=90 / red<90), gap count, onSelectContract callback LedgerSequenceGapViewer — paginated table of missing sequence ranges (50/page); no-gaps success state; summary: total ranges + missing count EventCountComparison — Recharts BarChart: expected vs actual event counts by date ReconciliationHistory — table of past jobs: status badge, events recovered, progress bar (role=progressbar), estimated duration ManualReconciliationForm — date range + reason form; validation (start/end/order/ reason); onSubmit callback; job result display with jobId, status, duration; error state GapFillPreview — preview table of events to be recovered; item count; optional JSON export button DataQualityAnomalyAlert — dismissible alert cards (role=alert) for >20% event count drops; shows drop%, expected, actual; onDismiss cb index.ts — barrel exports New page: app/admin/data-quality/page.tsx — composes all components; export-report button (PDF/txt download); tabbed detail: Gaps / Event Counts / Gap Fill / Reconcile; stubs ready for Backend #100 Tests (__tests__/data-quality.test.tsx — 50 tests): - DataQualityScorecard: rows, completeness %, green/red colours, click callback, gap count, progressbar aria, loading/empty states - LedgerSequenceGapViewer: no-gaps state, gap rows, missing count, pagination (next page, prev disabled on first page) - EventCountComparison: chart render, bar-chart mock, empty state, aria-label - ReconciliationHistory: rows, status badges, recovered count, progress %, role, empty - ManualReconciliationForm: all validation paths (start/end/order/reason), onSubmit args, job result display, error state, disabled during submit - GapFillPreview: row count, item count text, export button, onExport callback, empty - DataQualityAnomalyAlert: renders, role=alert, drop%, counts, dismiss hides one, onDismiss callback, all dismissed = empty DOM, empty alerts = empty DOM Co-authored-by: Jess <jessicaetsemobor@users.noreply.github.com>
1 parent 2d0189f commit fe7fd22

11 files changed

Lines changed: 1189 additions & 0 deletions

File tree

soroscan-frontend/__tests__/data-quality.test.tsx

Lines changed: 404 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { Tabs } from '@/components/ui/tabs';
5+
import {
6+
DataQualityScorecard, LedgerSequenceGapViewer, EventCountComparison,
7+
ReconciliationHistory, ManualReconciliationForm, GapFillPreview,
8+
DataQualityAnomalyAlert,
9+
} from '@/components/data-quality';
10+
import type {
11+
DataQualityMetrics, ReconciliationJob, EventCountPoint,
12+
GapFillPreviewItem, AnomalyAlert, DateRange,
13+
} from '@/components/data-quality';
14+
15+
/** Stubs — replace with real GraphQL calls once Backend #100 ships. */
16+
async function fetchMetrics(_contractId: string): Promise<DataQualityMetrics | null> { return null; }
17+
async function fetchJobs(): Promise<ReconciliationJob[]> { return []; }
18+
async function fetchEventCounts(_contractId: string, _range: DateRange): Promise<EventCountPoint[]> { return []; }
19+
async function fetchGapFillPreview(_contractId: string): Promise<GapFillPreviewItem[]> { return []; }
20+
async function fetchAnomalyAlerts(): Promise<AnomalyAlert[]> { return []; }
21+
22+
async function triggerReconciliation(contractId: string, dateRange: DateRange, reason: string) {
23+
void contractId; void dateRange; void reason;
24+
return { jobId: 'job-stub', status: 'pending', estimatedDurationSecs: null };
25+
}
26+
27+
function exportReportPDF(contractId: string) {
28+
const content = `Data Quality Report\nContract: ${contractId}\nGenerated: ${new Date().toISOString()}`;
29+
const blob = new Blob([content], { type: 'text/plain' });
30+
const url = URL.createObjectURL(blob);
31+
const a = document.createElement('a');
32+
a.href = url;
33+
a.download = `dq-report-${contractId.slice(0, 8)}.txt`;
34+
a.click();
35+
URL.revokeObjectURL(url);
36+
}
37+
38+
export default function DataQualityPage() {
39+
const [selectedContractId, setSelectedContractId] = React.useState<string | null>(null);
40+
const [allMetrics, setAllMetrics] = React.useState<DataQualityMetrics[]>([]);
41+
const [jobs, setJobs] = React.useState<ReconciliationJob[]>([]);
42+
const [eventCounts, setEventCounts] = React.useState<EventCountPoint[]>([]);
43+
const [gapFillItems, setGapFillItems] = React.useState<GapFillPreviewItem[]>([]);
44+
const [anomalies, setAnomalies] = React.useState<AnomalyAlert[]>([]);
45+
const [isLoading, setIsLoading] = React.useState(true);
46+
47+
React.useEffect(() => {
48+
setIsLoading(true);
49+
Promise.all([fetchJobs(), fetchAnomalyAlerts()]).then(([j, a]) => {
50+
setJobs(j); setAnomalies(a); setIsLoading(false);
51+
});
52+
}, []);
53+
54+
const handleSelectContract = React.useCallback(async (contractId: string) => {
55+
setSelectedContractId(contractId);
56+
const [m, counts, preview] = await Promise.all([
57+
fetchMetrics(contractId),
58+
fetchEventCounts(contractId, { start: '2024-01-01', end: new Date().toISOString().slice(0, 10) }),
59+
fetchGapFillPreview(contractId),
60+
]);
61+
if (m) setAllMetrics((prev) => [...prev.filter((x) => x.contractId !== contractId), m]);
62+
setEventCounts(counts);
63+
setGapFillItems(preview);
64+
}, []);
65+
66+
const selectedMetrics = allMetrics.find((m) => m.contractId === selectedContractId) ?? null;
67+
68+
const detailTabs = selectedMetrics
69+
? [
70+
{ id: 'gaps', title: `Gaps (${selectedMetrics.gaps.length})`,
71+
content: <LedgerSequenceGapViewer gaps={selectedMetrics.gaps} totalEvents={selectedMetrics.actualEventCount} /> },
72+
{ id: 'counts', title: 'Event Counts',
73+
content: <EventCountComparison data={eventCounts} /> },
74+
{ id: 'gapfill', title: 'Gap Fill Preview',
75+
content: <GapFillPreview items={gapFillItems}
76+
onExport={() => {
77+
const blob = new Blob([JSON.stringify(gapFillItems, null, 2)], { type: 'application/json' });
78+
const url = URL.createObjectURL(blob);
79+
const a = document.createElement('a'); a.href = url;
80+
a.download = 'gap-fill-preview.json'; a.click(); URL.revokeObjectURL(url);
81+
}} /> },
82+
{ id: 'reconcile', title: 'Reconcile',
83+
content: <ManualReconciliationForm contractId={selectedContractId!} onSubmit={triggerReconciliation} /> },
84+
]
85+
: [];
86+
87+
return (
88+
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6" data-testid="data-quality-page">
89+
<div className="flex flex-wrap items-start justify-between gap-3">
90+
<div>
91+
<h1 className="text-base font-mono font-semibold text-green-400">Data Quality</h1>
92+
<p className="text-xs font-mono text-gray-500 mt-0.5">Event completeness & reconciliation</p>
93+
</div>
94+
{selectedContractId && (
95+
<button type="button"
96+
onClick={() => exportReportPDF(selectedContractId)}
97+
data-testid="export-pdf-button"
98+
aria-label="Export reconciliation report"
99+
className="px-3 py-1.5 text-xs font-mono rounded border border-green-800 bg-gray-900 text-green-400 hover:bg-green-900/30 transition-colors">
100+
↓ Export Report
101+
</button>
102+
)}
103+
</div>
104+
105+
<DataQualityAnomalyAlert alerts={anomalies} />
106+
107+
<div className="space-y-1">
108+
<p className="text-xs font-mono text-gray-500 uppercase tracking-wider">Completeness Scorecard</p>
109+
<DataQualityScorecard metrics={allMetrics} isLoading={isLoading}
110+
onSelectContract={handleSelectContract} />
111+
</div>
112+
113+
<div className="space-y-1">
114+
<p className="text-xs font-mono text-gray-500 uppercase tracking-wider">Reconciliation History</p>
115+
<ReconciliationHistory jobs={jobs} />
116+
</div>
117+
118+
{selectedContractId && detailTabs.length > 0 && (
119+
<div className="space-y-3">
120+
<div className="flex items-center justify-between">
121+
<h2 className="text-sm font-mono text-green-300">
122+
Detail: <span className="text-green-400">{selectedContractId.slice(0, 16)}</span>
123+
</h2>
124+
<button type="button" onClick={() => setSelectedContractId(null)}
125+
className="text-xs font-mono text-gray-500 hover:text-gray-300">✕ Close</button>
126+
</div>
127+
<Tabs items={detailTabs} />
128+
</div>
129+
)}
130+
</div>
131+
);
132+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { cn } from '@/lib/utils';
5+
import type { AnomalyAlert } from './types';
6+
7+
export interface DataQualityAnomalyAlertProps {
8+
alerts: AnomalyAlert[];
9+
onDismiss?: (id: string) => void;
10+
className?: string;
11+
}
12+
13+
export function DataQualityAnomalyAlert({ alerts, onDismiss, className }: DataQualityAnomalyAlertProps) {
14+
const [dismissed, setDismissed] = React.useState<Set<string>>(new Set());
15+
16+
const visible = alerts.filter((a) => !dismissed.has(a.id));
17+
18+
if (visible.length === 0) return null;
19+
20+
const handleDismiss = (id: string) => {
21+
setDismissed((prev) => new Set([...prev, id]));
22+
onDismiss?.(id);
23+
};
24+
25+
return (
26+
<div className={cn('space-y-2', className)} data-testid="anomaly-alerts-container">
27+
{visible.map((alert) => (
28+
<div
29+
key={alert.id}
30+
role="alert"
31+
data-testid={`anomaly-alert-${alert.id}`}
32+
className="flex items-start gap-3 px-4 py-3 rounded-lg border border-red-800 bg-red-950/20"
33+
>
34+
<span className="text-red-400 text-base mt-0.5 shrink-0" aria-hidden="true"></span>
35+
<div className="flex-1 min-w-0">
36+
<p className="text-xs font-mono font-semibold text-red-400">
37+
Event count anomaly detected — {alert.dropPercent.toFixed(1)}% drop
38+
</p>
39+
<p className="text-[10px] font-mono text-gray-500 mt-0.5">
40+
Contract <span className="text-gray-300">{alert.contractId.slice(0, 16)}</span>
41+
{' · '}Expected <span className="text-gray-300">{alert.expectedCount.toLocaleString()}</span>
42+
{', '}actual <span className="text-red-400">{alert.actualCount.toLocaleString()}</span>
43+
{' · '}{new Date(alert.detectedAt).toLocaleString()}
44+
</p>
45+
</div>
46+
<button
47+
type="button"
48+
onClick={() => handleDismiss(alert.id)}
49+
aria-label={`Dismiss anomaly alert for ${alert.contractId}`}
50+
data-testid={`dismiss-alert-${alert.id}`}
51+
className="shrink-0 text-gray-600 hover:text-gray-300 text-xs transition-colors"
52+
>
53+
54+
</button>
55+
</div>
56+
))}
57+
</div>
58+
);
59+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { cn } from '@/lib/utils';
5+
import type { DataQualityMetrics } from './types';
6+
7+
export interface DataQualityScorecardProps {
8+
metrics: DataQualityMetrics[];
9+
isLoading?: boolean;
10+
onSelectContract?: (contractId: string) => void;
11+
className?: string;
12+
}
13+
14+
function CompletenessBar({ percent }: { percent: number }) {
15+
const clamped = Math.max(0, Math.min(100, percent));
16+
const color =
17+
clamped >= 99 ? 'bg-green-500' :
18+
clamped >= 90 ? 'bg-yellow-500' : 'bg-red-500';
19+
return (
20+
<div className="w-full h-1.5 rounded-full bg-gray-800" role="progressbar"
21+
aria-valuenow={clamped} aria-valuemin={0} aria-valuemax={100}
22+
aria-label={`${clamped.toFixed(1)}% complete`}>
23+
<div className={cn('h-full rounded-full transition-all', color)}
24+
style={{ width: `${clamped}%` }} />
25+
</div>
26+
);
27+
}
28+
29+
export function DataQualityScorecard({
30+
metrics, isLoading = false, onSelectContract, className,
31+
}: DataQualityScorecardProps) {
32+
if (isLoading) {
33+
return (
34+
<div className="text-sm font-mono text-gray-500 animate-pulse py-6" data-testid="scorecard-loading">
35+
Loading quality metrics…
36+
</div>
37+
);
38+
}
39+
if (metrics.length === 0) {
40+
return (
41+
<p className="text-sm font-mono text-gray-500 py-4" data-testid="scorecard-empty">
42+
No contract quality data available.
43+
</p>
44+
);
45+
}
46+
47+
return (
48+
<div className={cn('space-y-2', className)} data-testid="data-quality-scorecard">
49+
{metrics.map((m) => (
50+
<button
51+
key={m.contractId}
52+
type="button"
53+
onClick={() => onSelectContract?.(m.contractId)}
54+
data-testid={`scorecard-row-${m.contractId}`}
55+
className="w-full flex items-center gap-4 px-4 py-3 rounded-lg border border-green-900 bg-gray-950 hover:bg-gray-900 transition-colors text-left"
56+
aria-label={`${m.contractId}: ${m.completenessPercent.toFixed(1)}% complete`}
57+
>
58+
<span className="w-32 shrink-0 font-mono text-xs text-green-300 truncate">
59+
{m.contractId.slice(0, 12)}
60+
</span>
61+
<div className="flex-1 space-y-1">
62+
<CompletenessBar percent={m.completenessPercent} />
63+
</div>
64+
<span
65+
className={cn(
66+
'shrink-0 w-16 text-right font-mono text-sm font-semibold',
67+
m.completenessPercent >= 99 ? 'text-green-400' :
68+
m.completenessPercent >= 90 ? 'text-yellow-400' : 'text-red-400'
69+
)}
70+
data-testid={`completeness-pct-${m.contractId}`}
71+
>
72+
{m.completenessPercent.toFixed(1)}%
73+
</span>
74+
<span className="shrink-0 text-[10px] font-mono text-gray-600 w-24 text-right">
75+
{m.gaps.length} gap{m.gaps.length !== 1 ? 's' : ''}
76+
</span>
77+
</button>
78+
))}
79+
</div>
80+
);
81+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import {
5+
BarChart, Bar, XAxis, YAxis, CartesianGrid,
6+
Tooltip, Legend, ResponsiveContainer,
7+
} from 'recharts';
8+
import { cn } from '@/lib/utils';
9+
import type { EventCountPoint } from './types';
10+
11+
export interface EventCountComparisonProps {
12+
data: EventCountPoint[];
13+
className?: string;
14+
}
15+
16+
export function EventCountComparison({ data, className }: EventCountComparisonProps) {
17+
if (data.length === 0) {
18+
return (
19+
<div className="flex items-center justify-center h-40 text-sm font-mono text-gray-500 border border-green-900 rounded-lg bg-gray-950"
20+
data-testid="event-count-chart-empty">
21+
No event count data available.
22+
</div>
23+
);
24+
}
25+
26+
const chartData = data.map((d) => ({
27+
date: d.date.slice(5), // MM-DD
28+
Expected: d.expected,
29+
Actual: d.actual,
30+
}));
31+
32+
return (
33+
<div className={cn('space-y-2', className)} data-testid="event-count-comparison"
34+
aria-label="Expected vs actual event counts">
35+
<ResponsiveContainer width="100%" height={220}>
36+
<BarChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
37+
<CartesianGrid strokeDasharray="3 3" stroke="#14532d" opacity={0.4} />
38+
<XAxis dataKey="date" tick={{ fontSize: 10, fontFamily: 'monospace', fill: '#6b7280' }}
39+
tickLine={false} axisLine={{ stroke: '#14532d' }} />
40+
<YAxis tick={{ fontSize: 10, fontFamily: 'monospace', fill: '#6b7280' }}
41+
tickLine={false} axisLine={{ stroke: '#14532d' }} />
42+
<Tooltip contentStyle={{ background: '#030712', border: '1px solid #166534',
43+
borderRadius: 4, fontFamily: 'monospace', fontSize: 11, color: '#4ade80' }} />
44+
<Legend wrapperStyle={{ fontSize: 10, fontFamily: 'monospace', color: '#6b7280' }} />
45+
<Bar dataKey="Expected" fill="#166534" radius={[2, 2, 0, 0]} />
46+
<Bar dataKey="Actual" fill="#4ade80" radius={[2, 2, 0, 0]} />
47+
</BarChart>
48+
</ResponsiveContainer>
49+
</div>
50+
);
51+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { cn } from '@/lib/utils';
5+
import type { GapFillPreviewItem } from './types';
6+
7+
export interface GapFillPreviewProps {
8+
items: GapFillPreviewItem[];
9+
onExport?: () => void;
10+
className?: string;
11+
}
12+
13+
export function GapFillPreview({ items, onExport, className }: GapFillPreviewProps) {
14+
if (items.length === 0) {
15+
return (
16+
<p className="text-sm font-mono text-gray-500 py-4" data-testid="gap-fill-preview-empty">
17+
No events to preview for gap fill.
18+
</p>
19+
);
20+
}
21+
22+
return (
23+
<div className={cn('space-y-3', className)} data-testid="gap-fill-preview">
24+
<div className="flex items-center justify-between text-xs font-mono">
25+
<span className="text-gray-500">
26+
<span className="text-blue-400 font-semibold">{items.length.toLocaleString()}</span> events would be recovered
27+
</span>
28+
{onExport && (
29+
<button type="button" onClick={onExport}
30+
data-testid="gap-fill-export-button"
31+
aria-label="Export gap fill preview as JSON"
32+
className="px-3 py-1 rounded border border-green-800 bg-gray-900 text-green-400 hover:bg-green-900/30 transition-colors">
33+
↓ Export
34+
</button>
35+
)}
36+
</div>
37+
38+
<div className="overflow-y-auto max-h-64 rounded-lg border border-blue-900/50">
39+
<table className="w-full text-xs font-mono">
40+
<thead className="bg-gray-900 border-b border-blue-900/50 sticky top-0">
41+
<tr className="text-gray-500">
42+
<th className="text-right px-4 py-2 font-normal">Sequence</th>
43+
<th className="text-left px-4 py-2 font-normal">Est. Timestamp</th>
44+
<th className="text-left px-4 py-2 font-normal">Contract</th>
45+
</tr>
46+
</thead>
47+
<tbody>
48+
{items.map((item, idx) => (
49+
<tr key={item.sequence}
50+
className="border-t border-gray-800 hover:bg-gray-900/40"
51+
data-testid={`gap-fill-row-${idx}`}>
52+
<td className="px-4 py-2 text-right text-blue-300">{item.sequence.toLocaleString()}</td>
53+
<td className="px-4 py-2 text-gray-400">{item.estimatedTimestamp}</td>
54+
<td className="px-4 py-2 text-green-300 truncate max-w-[8rem]">{item.contractId.slice(0, 12)}</td>
55+
</tr>
56+
))}
57+
</tbody>
58+
</table>
59+
</div>
60+
</div>
61+
);
62+
}

0 commit comments

Comments
 (0)