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:
@@ -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;
|
||||
|
||||
@@ -19,6 +19,16 @@ export function createDashboardRouter(): Router {
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/dashboard/activity?days=14 — message volume over time
|
||||
router.get(
|
||||
"/dashboard/activity",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
||||
const activity = await dashboardService.getActivity(days);
|
||||
res.json(activity);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/dashboard/users — paginated user list with profiles
|
||||
router.get(
|
||||
"/dashboard/users",
|
||||
|
||||
@@ -15,6 +15,11 @@ export class DashboardService {
|
||||
return dashboardRepository.getStats();
|
||||
}
|
||||
|
||||
async getActivity(days: number) {
|
||||
logger.debug({ days }, "Fetching dashboard activity");
|
||||
return dashboardRepository.getActivity(days);
|
||||
}
|
||||
|
||||
async listUsers(query: ListUsersQuery) {
|
||||
logger.debug({ query }, "Listing dashboard users");
|
||||
return dashboardRepository.listUsers(query);
|
||||
|
||||
Reference in New Issue
Block a user