Add app opens & services activity charts to admin dashboard

- New `app_opens` table (id, user_id, app_id, created_at) and Drizzle
  schema; exported from lib/db schema index.
- New `POST /api/apps/{id}/open` endpoint logs an open for the current
  user (auth required, 204 on success, 404 for unknown app id).
- Extended `GET /api/stats/admin` with appOpensByDay/appOpensLast7Days/
  appOpensPrev7Days and servicesCreatedByDay/servicesCreatedLast7Days,
  refactored around a shared buildSeries helper.
- Regenerated api-client/zod and added two new bar charts (App opens,
  Services added) to the admin Dashboard alongside the existing
  Sign-ups chart, with EN/AR translations.
- Home page fires bare `logAppOpen(id, { keepalive: true })` before
  navigating so the request survives client-side route changes; the
  bottom dock buttons also use this helper.
- Restructured SortableAppIcon so the click target and dnd-kit
  listeners live on the same <button>, fixing a click-vs-drag
  interaction that prevented the open event from firing in tests.

Rebase notes:
- home.tsx: incoming main reworked AppIcon styling, the apps grid
  header (with count and empty state), and the dock button. Kept all
  incoming visual changes while preserving this task's
  AppIconContent fragment, single-button SortableAppIcon, and
  openApp() wiring (so dock + grid both log opens).
- opengraph.jpg: kept incoming binary version.

E2E verified: clicking app icons increments app_opens; admin dashboard
renders all three charts.
This commit is contained in:
Riyadh
2026-04-20 15:16:19 +00:00
parent 10c8242511
commit d15aceefca
13 changed files with 393 additions and 39 deletions
+23
View File
@@ -8,6 +8,7 @@ import {
rolePermissionsTable,
rolesTable,
userAppOrdersTable,
appOpensTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
@@ -187,6 +188,28 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
res.json(app);
});
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const params = GetAppParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [app] = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.id, params.data.id));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
await db.insert(appOpensTable).values({ userId, appId: app.id });
res.sendStatus(204);
});
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
const params = DeleteAppParams.safeParse(req.params);
if (!params.success) {
+60
View File
@@ -12,6 +12,7 @@ import {
usersTable,
messagesTable,
messageReadsTable,
appOpensTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
@@ -165,12 +166,71 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
}
const buildSeries = (
rows: { day: string; count: number }[],
): { date: string; count: number }[] => {
const map = new Map<string, number>();
for (const r of rows) map.set(r.day, Number(r.count));
const out: { 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);
out.push({ date: key, count: map.get(key) ?? 0 });
}
return out;
};
const appOpenRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`)
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
const appOpensByDay = buildSeries(appOpenRows);
const [appOpensLast7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`);
const [appOpensPrev7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(
and(
sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`,
sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`,
),
);
const servicesCreatedRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`)
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
const [servicesCreatedLast7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`);
res.json({
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
activeServices: Number(activeServicesRow?.count ?? 0),
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
signupsByDay,
appOpensByDay,
appOpensLast7Days: Number(appOpensLast7Row?.count ?? 0),
appOpensPrev7Days: Number(appOpensPrev7Row?.count ?? 0),
servicesCreatedByDay,
servicesCreatedLast7Days: Number(servicesCreatedLast7Row?.count ?? 0),
});
});