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:
Riyadh
2026-04-21 06:11:28 +00:00
parent ccfe0ea74a
commit 1e41798a06
8 changed files with 301 additions and 2 deletions
+67
View File
@@ -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

+7 -1
View File
@@ -234,7 +234,13 @@
"7d": "الأيام ٧ السابقة",
"30d": "الأيام ٣٠ السابقة",
"90d": "الأيام ٩٠ السابقة"
}
},
"topApps": "أكثر التطبيقات استخدامًا",
"topAppsSubtitle": "حسب الفتح في {{range}}",
"mostActiveUsers": "أكثر المستخدمين نشاطًا",
"mostActiveUsersSubtitle": "حسب فتح التطبيقات في {{range}}",
"leaderboardEmpty": "لا يوجد نشاط بعد",
"openCount": "{{count}} فتحة"
}
},
"notFound": {
+7 -1
View File
@@ -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": {
+119
View File
@@ -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 = {
+56
View File
@@ -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
+21
View File
@@ -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(),
}),
),
});