feat(admin): profile freshness panel in data-quality (#81 phase B.4)

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>
This commit is contained in:
2026-04-25 00:34:46 +00:00
parent 9e96540bcc
commit 4a42a6aabf
6 changed files with 167 additions and 3 deletions

View File

@@ -10,6 +10,19 @@ function Pct({ value }: { value: number }) {
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);
@@ -50,6 +63,45 @@ export default function DataQualityPage() {
</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">

View File

@@ -199,6 +199,14 @@ export function getRewardAnalytics(days = 30) {
}>(`/admin/reward-analytics?days=${days}`);
}
export interface FeatureFreshnessRow {
feature: string;
ttlSec: number;
totalEligible: number;
missing: number;
stale: number;
}
export function getDataQuality() {
return apiFetch<{
scoringCallsLast30d: number;
@@ -207,6 +215,7 @@ export function getDataQuality() {
totalTokens: number;
staleTokens: number;
dailyQuality: { date: string; total: number; withFeatures: number; avgCandidates: number }[];
profileFreshness: FeatureFreshnessRow[];
}>('/admin/data-quality');
}

File diff suppressed because one or more lines are too long