feat(dashboard): message activity timeline + moderation donut

Backend:
- GET /api/dashboard/activity?days=1..90 — daily buckets (messages,
  flagged, active_users) + hourly distribution last 24h
- clamped days param, reuses existing indexes (idx_messages_created,
  ai_status_created)

Frontend:
- ActivityChart: area chart messages+flagged per day, 7/14/30d range
- HourlyActivityChart: 24h bars with peak highlight
- ModerationDonut: clean/flagged/warned/error breakdown with live
  summary line (server X% clean)
- Dashboard layout: activity 2/3 + donut 1/3, hourly + top channels

Verified: endpoint returns real data from prod DB (784/1897/8 msgs
per day), tsc clean backend+frontend.
This commit is contained in:
asepharyana
2026-08-01 23:06:00 +07:00
parent 7d2bd75f6c
commit 78d514b73d
11 changed files with 514 additions and 7 deletions
@@ -82,6 +82,52 @@ export class DashboardRepository {
};
}
async getActivity(days: number) {
const db = getDatabase();
const sinceMs = Date.now() - days * 86400000;
const dayAgoMs = Date.now() - 86400000;
// Daily buckets (last N days)
const daily = await db.execute(sql`
SELECT
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(DISTINCT user_id)::int AS active_users
FROM ${pgMessagesTable}
WHERE created_at >= ${sinceMs}
GROUP BY day
ORDER BY day
`);
// Hourly distribution (last 24h)
const hourly = await db.execute(sql`
SELECT
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
FROM ${pgMessagesTable}
WHERE created_at >= ${dayAgoMs}
GROUP BY hour
ORDER BY hour
`);
return {
days,
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
day: String(r.day),
messages: Number(r.messages),
flagged: Number(r.flagged),
active_users: Number(r.active_users),
})),
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
hour: Number(r.hour),
messages: Number(r.messages),
flagged: Number(r.flagged),
})),
};
}
async listUsers(query: ListUsersQuery) {
const db = getDatabase();
const limit = query.limit ?? 20;