Adds a per-feature freshness summary to /admin/data-quality so the admin can spot features that are systematically stale or never computed: totalEligible — distinct users with tip_views in the last 30 days missing — eligible users with no row stored for the feature stale — eligible users whose stored row is past its TTL Backend exposes summarizeProfileFreshness() in profile/builder.ts; one query per feature joins eligible users LEFT JOIN profile rows. Coverage = (eligible − missing − stale) / eligible, colored green/yellow/red via the new PctGood helper (high-is-good, opposite of the existing Pct used for missing-feature/stale-token rates). Refs #81. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
142 lines
6.7 KiB
TypeScript
142 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { AdminShell } from '@/components/AdminShell';
|
|
import { getDataQuality } from '@/lib/api';
|
|
|
|
function Pct({ value }: { value: number }) {
|
|
const pct = (value * 100).toFixed(1);
|
|
const color = value < 0.05 ? 'text-green-400' : value < 0.2 ? 'text-yellow-400' : 'text-red-400';
|
|
return <span className={color}>{pct}%</span>;
|
|
}
|
|
|
|
function PctGood({ value }: { value: number }) {
|
|
const pct = (value * 100).toFixed(1);
|
|
const color = value > 0.95 ? 'text-green-400' : value > 0.8 ? 'text-yellow-400' : 'text-red-400';
|
|
return <span className={color}>{pct}%</span>;
|
|
}
|
|
|
|
function formatTtl(sec: number): string {
|
|
if (sec < 60) return `${sec}s`;
|
|
if (sec < 3600) return `${Math.round(sec / 60)}m`;
|
|
if (sec < 86400) return `${Math.round(sec / 3600)}h`;
|
|
return `${Math.round(sec / 86400)}d`;
|
|
}
|
|
|
|
export default function DataQualityPage() {
|
|
const [data, setData] = useState<Awaited<ReturnType<typeof getDataQuality>> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
getDataQuality()
|
|
.then(setData)
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
return (
|
|
<AdminShell>
|
|
<div className="space-y-6">
|
|
<h1 className="text-xl font-semibold">Data quality</h1>
|
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
{loading && <p className="text-gray-500 text-sm">Loading…</p>}
|
|
|
|
{data && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
|
<div className="text-xs text-gray-500 mb-1">Scoring calls (30d)</div>
|
|
<div className="text-2xl font-semibold">{data.scoringCallsLast30d}</div>
|
|
</div>
|
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
|
<div className="text-xs text-gray-500 mb-1">Missing feature rate</div>
|
|
<div className="text-2xl font-semibold"><Pct value={data.missingFeatureRate} /></div>
|
|
</div>
|
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
|
<div className="text-xs text-gray-500 mb-1">Integration tokens</div>
|
|
<div className="text-2xl font-semibold">{data.totalTokens}</div>
|
|
</div>
|
|
<div className="bg-gray-900 border border-gray-800 rounded p-4">
|
|
<div className="text-xs text-gray-500 mb-1">Stale token rate (>7d)</div>
|
|
<div className="text-2xl font-semibold"><Pct value={data.staleTokenRate} /></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile freshness — #81 phase B.4 */}
|
|
<div className="space-y-2">
|
|
<h2 className="text-sm font-medium text-gray-400">Profile feature freshness</h2>
|
|
<p className="text-xs text-gray-600">
|
|
Eligible = users with any tip activity in the last 30 days. Stale = stored row past its TTL. Missing = no row computed yet.
|
|
</p>
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
|
<th className="py-2 pr-4">Feature</th>
|
|
<th className="py-2 pr-4">TTL</th>
|
|
<th className="py-2 pr-4">Eligible</th>
|
|
<th className="py-2 pr-4">Missing</th>
|
|
<th className="py-2 pr-4">Stale</th>
|
|
<th className="py-2">Coverage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.profileFreshness.map((r) => {
|
|
const fresh = r.totalEligible - r.missing - r.stale;
|
|
const coverage = r.totalEligible > 0 ? fresh / r.totalEligible : 0;
|
|
return (
|
|
<tr key={r.feature} className="border-b border-gray-800/50">
|
|
<td className="py-1.5 pr-4 font-mono text-gray-400">{r.feature}</td>
|
|
<td className="py-1.5 pr-4 text-gray-500 tabular-nums">{formatTtl(r.ttlSec)}</td>
|
|
<td className="py-1.5 pr-4 text-gray-300 tabular-nums">{r.totalEligible}</td>
|
|
<td className={`py-1.5 pr-4 tabular-nums ${r.missing > 0 ? 'text-orange-400' : 'text-gray-500'}`}>{r.missing}</td>
|
|
<td className={`py-1.5 pr-4 tabular-nums ${r.stale > 0 ? 'text-yellow-400' : 'text-gray-500'}`}>{r.stale}</td>
|
|
<td className="py-1.5"><PctGood value={coverage} /></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{data.profileFreshness.length === 0 && (
|
|
<tr><td colSpan={6} className="py-4 text-center text-gray-600">No features registered</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h2 className="text-sm font-medium text-gray-400">Daily feature completeness (14d)</h2>
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-gray-800 text-gray-500 text-left">
|
|
<th className="py-2 pr-4">Date</th>
|
|
<th className="py-2 pr-4">Scoring calls</th>
|
|
<th className="py-2 pr-4">With features</th>
|
|
<th className="py-2 pr-4">Coverage</th>
|
|
<th className="py-2">Avg candidates</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.dailyQuality.map((row) => {
|
|
const coverage = row.total > 0 ? row.withFeatures / row.total : 0;
|
|
return (
|
|
<tr key={row.date} className="border-b border-gray-800/50">
|
|
<td className="py-1.5 pr-4 font-mono text-gray-500">{row.date}</td>
|
|
<td className="py-1.5 pr-4 text-gray-300">{row.total}</td>
|
|
<td className="py-1.5 pr-4 text-gray-300">{row.withFeatures}</td>
|
|
<td className="py-1.5 pr-4"><Pct value={coverage} /></td>
|
|
<td className="py-1.5 text-gray-300">{row.avgCandidates?.toFixed(1) ?? '—'}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{data.dailyQuality.length === 0 && (
|
|
<tr><td colSpan={5} className="py-4 text-center text-gray-600">No data yet</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</AdminShell>
|
|
);
|
|
}
|