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

@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeAll } from 'vitest';
import express from 'express';
import * as http from 'http';
import { makeTestDb } from '../../test/db.js';
import { users, integrationTokens, tipViews, tipFeedback, tipScores } from '../../db/schema.js';
import { users, integrationTokens, tipViews, tipFeedback, tipScores, userProfileFeatures } from '../../db/schema.js';
// ---- in-memory DB ----
const testDb = makeTestDb();
@@ -422,6 +422,56 @@ describe('GET /api/admin/users/:id — edge cases', () => {
});
});
describe('GET /api/admin/data-quality — #81 phase B.4 profile freshness', () => {
type Body = {
profileFreshness: Array<{
feature: string;
ttlSec: number;
totalEligible: number;
missing: number;
stale: number;
}>;
};
it('reports each registered feature once', async () => {
const { server, call } = await startServer(buildApp());
try {
const { status, body } = await call('GET', '/api/admin/data-quality');
expect(status).toBe(200);
const b = body as Body;
const names = b.profileFreshness.map((r) => r.feature).sort();
expect(names).toEqual([
'completion_rate_30d',
'dismiss_rate_30d',
'mean_dwell_ms_30d',
'preferred_hour',
'tip_volume_30d',
]);
} finally {
server.close();
}
});
it('counts eligible users (with tip_views in 30d) and flags missing rows', async () => {
// Reset profile rows so this test is independent of any previous rebuild test
// that may have populated rows for some users.
await testDb.delete(userProfileFeatures);
const { server, call } = await startServer(buildApp());
try {
const { body } = await call('GET', '/api/admin/data-quality');
const b = body as Body;
// Seed has tip_views for user-1 and user-2 within 30d → 2 eligible.
const completion = b.profileFreshness.find((r) => r.feature === 'completion_rate_30d')!;
expect(completion.totalEligible).toBe(2);
// No profile rows seeded → both missing
expect(completion.missing).toBe(2);
expect(completion.stale).toBe(0);
} finally {
server.close();
}
});
});
describe('GET /api/admin/reward-analytics — #92 quality breakdowns', () => {
type Row = {
key: string | null;