Show top apps and most-active users on the admin dashboard (Task #21)
Added two leaderboard panels to the admin dashboard that surface which
apps are most popular and which users drive the most activity in the
selected time range.
Backend (artifacts/api-server/src/routes/stats.ts):
- Extended GET /api/stats/admin to also return:
- topApps: top 5 apps by app_opens count, with id, slug, names,
iconName, color, count
- mostActiveUsers: top 5 users by app_opens count, with id, username,
displayNames, avatarUrl, count
- Both lists honor the existing `range` query param (7d/30d/90d) so
they stay in sync with the trend charts. Task wording said "last 7
days" because 7d is the default; using the selected range is a small
intentional improvement that matches the rest of the dashboard.
API spec (lib/api-spec/openapi.yaml):
- Added TopAppItem and TopUserItem schemas.
- Added topApps and mostActiveUsers to AdminStats and made them
required. Regenerated api-client-react and api-zod via codegen.
Frontend (artifacts/teaboy-os/src/pages/admin.tsx):
- Added two new panels to DashboardSection rendered in a 2-column grid
between the trend charts and the recent activity card.
- Each row shows rank, color/initial, name (i18n), count, and a
proportional progress bar. Empty state when no activity yet.
i18n (artifacts/teaboy-os/src/locales/{ar,en}.json):
- Added admin.dashboard.topApps, mostActiveUsers, *Subtitle,
leaderboardEmpty, openCount keys.
Verified with end-to-end test: admin login, dashboard renders both
panels with seeded data, range switch updates subtitles to "Last 30
days".
This commit is contained in:
@@ -215,6 +215,71 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
.from(servicesTable)
|
||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
const topAppsRows = await db
|
||||
.select({
|
||||
appId: appsTable.id,
|
||||
slug: appsTable.slug,
|
||||
nameAr: appsTable.nameAr,
|
||||
nameEn: appsTable.nameEn,
|
||||
iconName: appsTable.iconName,
|
||||
color: appsTable.color,
|
||||
count: sql<number>`count(${appOpensTable.id})`,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.innerJoin(appsTable, eq(appOpensTable.appId, appsTable.id))
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(
|
||||
appsTable.id,
|
||||
appsTable.slug,
|
||||
appsTable.nameAr,
|
||||
appsTable.nameEn,
|
||||
appsTable.iconName,
|
||||
appsTable.color,
|
||||
)
|
||||
.orderBy(sql`count(${appOpensTable.id}) desc`)
|
||||
.limit(5);
|
||||
|
||||
const topApps = topAppsRows.map((r) => ({
|
||||
appId: r.appId,
|
||||
slug: r.slug,
|
||||
nameAr: r.nameAr,
|
||||
nameEn: r.nameEn,
|
||||
iconName: r.iconName,
|
||||
color: r.color,
|
||||
count: Number(r.count),
|
||||
}));
|
||||
|
||||
const mostActiveUsersRows = await db
|
||||
.select({
|
||||
userId: usersTable.id,
|
||||
username: usersTable.username,
|
||||
displayNameAr: usersTable.displayNameAr,
|
||||
displayNameEn: usersTable.displayNameEn,
|
||||
avatarUrl: usersTable.avatarUrl,
|
||||
count: sql<number>`count(${appOpensTable.id})`,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(
|
||||
usersTable.id,
|
||||
usersTable.username,
|
||||
usersTable.displayNameAr,
|
||||
usersTable.displayNameEn,
|
||||
usersTable.avatarUrl,
|
||||
)
|
||||
.orderBy(sql`count(${appOpensTable.id}) desc`)
|
||||
.limit(5);
|
||||
|
||||
const mostActiveUsers = mostActiveUsersRows.map((r) => ({
|
||||
userId: r.userId,
|
||||
username: r.username,
|
||||
displayNameAr: r.displayNameAr,
|
||||
displayNameEn: r.displayNameEn,
|
||||
avatarUrl: r.avatarUrl,
|
||||
count: Number(r.count),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
range,
|
||||
rangeDays,
|
||||
@@ -228,6 +293,8 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
appOpensPrevRange: Number(appOpensPrevRangeRow?.count ?? 0),
|
||||
servicesCreatedByDay,
|
||||
servicesCreatedInRange: Number(servicesCreatedInRangeRow?.count ?? 0),
|
||||
topApps,
|
||||
mostActiveUsers,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -234,7 +234,13 @@
|
||||
"7d": "الأيام ٧ السابقة",
|
||||
"30d": "الأيام ٣٠ السابقة",
|
||||
"90d": "الأيام ٩٠ السابقة"
|
||||
}
|
||||
},
|
||||
"topApps": "أكثر التطبيقات استخدامًا",
|
||||
"topAppsSubtitle": "حسب الفتح في {{range}}",
|
||||
"mostActiveUsers": "أكثر المستخدمين نشاطًا",
|
||||
"mostActiveUsersSubtitle": "حسب فتح التطبيقات في {{range}}",
|
||||
"leaderboardEmpty": "لا يوجد نشاط بعد",
|
||||
"openCount": "{{count}} فتحة"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -234,7 +234,13 @@
|
||||
"7d": "Previous 7 days",
|
||||
"30d": "Previous 30 days",
|
||||
"90d": "Previous 90 days"
|
||||
}
|
||||
},
|
||||
"topApps": "Top apps",
|
||||
"topAppsSubtitle": "By opens in {{range}}",
|
||||
"mostActiveUsers": "Most active users",
|
||||
"mostActiveUsersSubtitle": "By app opens in {{range}}",
|
||||
"leaderboardEmpty": "No activity yet",
|
||||
"openCount": "{{count}} opens"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -908,6 +908,10 @@ function DashboardSection({
|
||||
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
|
||||
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
|
||||
const servicesCreatedTotal = adminStats?.servicesCreatedInRange ?? 0;
|
||||
const topApps = adminStats?.topApps ?? [];
|
||||
const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count));
|
||||
const mostActiveUsers = adminStats?.mostActiveUsers ?? [];
|
||||
const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count));
|
||||
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : 7);
|
||||
const rangeLabel = t(`admin.dashboard.range.${statsRange}`);
|
||||
const prevRangeLabel = t(`admin.dashboard.prevRange.${statsRange}`);
|
||||
@@ -1102,6 +1106,121 @@ function DashboardSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{t("admin.dashboard.topApps")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{t("admin.dashboard.topAppsSubtitle", { range: rangeLabel })}
|
||||
</div>
|
||||
</div>
|
||||
{topApps.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t("admin.dashboard.leaderboardEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{topApps.map((app, idx) => {
|
||||
const widthPct = (app.count / maxTopAppCount) * 100;
|
||||
return (
|
||||
<li
|
||||
key={app.appId}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${app.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{lang === "ar" ? app.nameAr : app.nameEn}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{app.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-sky-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground truncate">
|
||||
{t("admin.dashboard.mostActiveUsers")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{t("admin.dashboard.mostActiveUsersSubtitle", { range: rangeLabel })}
|
||||
</div>
|
||||
</div>
|
||||
{mostActiveUsers.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t("admin.dashboard.leaderboardEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{mostActiveUsers.map((u, idx) => {
|
||||
const widthPct = (u.count / maxActiveUserCount) * 100;
|
||||
const displayName =
|
||||
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
|
||||
const initial = (displayName || u.username || "?").trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<li
|
||||
key={u.userId}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full shrink-0 bg-emerald-100/70 text-emerald-700 flex items-center justify-center text-xs font-semibold">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
{displayName !== u.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
@{u.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{u.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-300 to-emerald-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("admin.dashboard.recentActivity")}
|
||||
|
||||
@@ -390,6 +390,28 @@ export type AdminStatsServicesCreatedByDayItem = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface TopAppItem {
|
||||
appId: number;
|
||||
slug: string;
|
||||
nameAr: string;
|
||||
nameEn: string;
|
||||
iconName: string;
|
||||
color: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TopUserItem {
|
||||
userId: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr?: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
range: AdminStatsRange;
|
||||
rangeDays: number;
|
||||
@@ -403,6 +425,8 @@ export interface AdminStats {
|
||||
appOpensPrevRange: number;
|
||||
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
|
||||
servicesCreatedInRange: number;
|
||||
topApps: TopAppItem[];
|
||||
mostActiveUsers: TopUserItem[];
|
||||
}
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
|
||||
@@ -1607,6 +1607,14 @@ components:
|
||||
- count
|
||||
servicesCreatedInRange:
|
||||
type: integer
|
||||
topApps:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopAppItem"
|
||||
mostActiveUsers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TopUserItem"
|
||||
required:
|
||||
- range
|
||||
- rangeDays
|
||||
@@ -1620,3 +1628,51 @@ components:
|
||||
- appOpensPrevRange
|
||||
- servicesCreatedByDay
|
||||
- servicesCreatedInRange
|
||||
- topApps
|
||||
- mostActiveUsers
|
||||
|
||||
TopAppItem:
|
||||
type: object
|
||||
properties:
|
||||
appId:
|
||||
type: integer
|
||||
slug:
|
||||
type: string
|
||||
nameAr:
|
||||
type: string
|
||||
nameEn:
|
||||
type: string
|
||||
iconName:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
required:
|
||||
- appId
|
||||
- slug
|
||||
- nameAr
|
||||
- nameEn
|
||||
- iconName
|
||||
- color
|
||||
- count
|
||||
|
||||
TopUserItem:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
displayNameAr:
|
||||
type: ["string", "null"]
|
||||
displayNameEn:
|
||||
type: ["string", "null"]
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
count:
|
||||
type: integer
|
||||
required:
|
||||
- userId
|
||||
- username
|
||||
- count
|
||||
|
||||
@@ -885,4 +885,25 @@ export const GetAdminStatsResponse = zod.object({
|
||||
}),
|
||||
),
|
||||
servicesCreatedInRange: zod.number(),
|
||||
topApps: zod.array(
|
||||
zod.object({
|
||||
appId: zod.number(),
|
||||
slug: zod.string(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
iconName: zod.string(),
|
||||
color: zod.string(),
|
||||
count: zod.number(),
|
||||
}),
|
||||
),
|
||||
mostActiveUsers: zod.array(
|
||||
zod.object({
|
||||
userId: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
count: zod.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user