Add admin trend stats and sign-up sparkline

Task #13: Show admin trends like new sign-ups this week.

Backend
- Added GET /api/stats/admin (admin-only) in artifacts/api-server/src/routes/stats.ts.
- Returns: newUsersLast7Days, newUsersPrev7Days, activeServices,
  inactiveServices, signupsByDay (7-day series with zero-filled days,
  computed via date_trunc on usersTable.createdAt).

API spec / codegen
- Added /stats/admin path and AdminStats schema in lib/api-spec/openapi.yaml.
- Re-ran @workspace/api-spec codegen, regenerating react-query hooks
  (useGetAdminStats) and zod schemas.

Frontend (artifacts/teaboy-os/src/pages/admin.tsx)
- Wired useGetAdminStats in AdminPage (enabled only for admins).
- Extended DashboardSection with two trend cards (new users 7d w/
  delta vs previous 7d, active services with inactive count) and a
  small bar chart of sign-ups over the last 7 days.
- Added matching i18n keys (en/ar) under admin.dashboard.

Notes
- Avoided sending all users to the client for trend computation, as
  recommended in the task brief.
- Verified pnpm typecheck passes and the new endpoint returns 401 to
  unauthenticated callers.

Replit-Task-Id: dd11d7f7-5569-47b4-878e-ad63043eda31
This commit is contained in:
riyadhafraa
2026-04-20 12:16:55 +00:00
parent dd9153ea4f
commit ada3ef6264
8 changed files with 330 additions and 5 deletions
+64 -1
View File
@@ -13,7 +13,7 @@ import {
messagesTable,
messageReadsTable,
} from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
import { requireAuth, requireAdmin } from "../middlewares/auth";
const router: IRouter = Router();
@@ -111,4 +111,67 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
});
});
router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise<void> => {
const now = new Date();
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const sevenDaysAgo = new Date(startOfTodayUtc.getTime() - 6 * 24 * 60 * 60 * 1000);
const fourteenDaysAgo = new Date(startOfTodayUtc.getTime() - 13 * 24 * 60 * 60 * 1000);
const last7Start = sevenDaysAgo;
const prev7Start = fourteenDaysAgo;
const [newUsersLast7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`);
const [newUsersPrev7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
.where(
and(
sql`${usersTable.createdAt} >= ${prev7Start.toISOString()}`,
sql`${usersTable.createdAt} < ${last7Start.toISOString()}`,
),
);
const [activeServicesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(eq(servicesTable.isAvailable, true));
const [inactiveServicesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(eq(servicesTable.isAvailable, false));
const signupRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`)
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
const countsByDay = new Map<string, number>();
for (const row of signupRows) {
countsByDay.set(row.day, Number(row.count));
}
const signupsByDay: { date: string; count: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
}
res.json({
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
activeServices: Number(activeServicesRow?.count ?? 0),
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
signupsByDay,
});
});
export default router;