Add admin recent-opens drill-in popup for top apps and users

Task #41: Show a usage history popup when admins click a top
apps row or a most-active users row on the admin dashboard.

Changes:
- API: extracted the existing range/from/to parser in
  artifacts/api-server/src/routes/stats.ts into a shared
  parseRangeWindow() helper so the same window logic powers
  the existing /stats/admin endpoint and two new ones.
- API: added GET /stats/admin/app-opens/by-app/:appId returning
  the last 100 opens for an app inside the selected window,
  including the user (id, username, displayName, avatarUrl)
  and a totalCount. requireAuth + requireAdmin guarded.
- API: added GET /stats/admin/app-opens/by-user/:userId with
  the same shape but per-app metadata for each open.
- Spec: added matching paths and AdminAppOpensByApp /
  AdminAppOpensByUser schemas in lib/api-spec/openapi.yaml,
  reran orval codegen for api-client-react and api-zod.
- UI: in artifacts/teaboy-os/src/pages/admin.tsx the Top apps
  and Most active users leaderboard rows now open a drill-in
  modal instead of immediately navigating away. The modal
  shows a scrollable timeline of recent opens (timestamp +
  user/app), respects the dashboard's current range selector
  (7d/30d/90d/custom), and exposes an "Edit app" / "View user"
  footer button that preserves the previous jump-to behaviour.
  Loading, error, and empty states are handled.
- i18n: added admin.dashboard.drillIn keys in en.json and
  ar.json (titles, subtitle, empty/error, footer buttons).

Verification: pnpm run typecheck passes.

Follow-ups proposed: #47 (clickable rows inside the popup),

Replit-Task-Id: f2fbe652-a788-4f25-9fb2-9ef2c535c8da
#48 (paginate beyond 100 opens).
This commit is contained in:
riyadhafraa
2026-04-21 10:17:41 +00:00
parent b910733adc
commit b6a0dc6ab9
9 changed files with 1230 additions and 26 deletions
+244 -20
View File
@@ -112,17 +112,32 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
});
});
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
const rangeParam = typeof req.query.range === "string" ? req.query.range : "7d";
const fromParam = typeof req.query.from === "string" ? req.query.from : undefined;
const toParam = typeof req.query.to === "string" ? req.query.to : undefined;
const allowedRanges = ["7d", "30d", "90d", "custom"] as const;
type RangeKey = typeof allowedRanges[number];
let range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
const DAY_MS = 24 * 60 * 60 * 1000;
const allowedRanges = ["7d", "30d", "90d", "custom"] as const;
type RangeKey = typeof allowedRanges[number];
type RangeWindow = {
range: RangeKey;
rangeStart: Date;
rangeEndExclusive: Date;
rangeDays: number;
prevRangeStart: Date;
rangeFromStr: string;
rangeToStr: string;
};
function parseRangeWindow(query: {
range?: unknown;
from?: unknown;
to?: unknown;
}): { window: RangeWindow } | { error: string } {
const rangeParam = typeof query.range === "string" ? query.range : "7d";
const fromParam = typeof query.from === "string" ? query.from : undefined;
const toParam = typeof query.to === "string" ? query.to : undefined;
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
? (rangeParam as RangeKey)
: "7d";
const DAY_MS = 24 * 60 * 60 * 1000;
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
const parseUtcDate = (s: string): Date | null => {
if (!isoDateRe.test(s)) return null;
@@ -141,28 +156,20 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
if (range === "custom") {
if (!fromParam || !toParam) {
res.status(400).json({
error: "Missing from/to: 'range=custom' requires both 'from' and 'to' as YYYY-MM-DD dates",
});
return;
return { error: "Missing from/to: 'range=custom' requires both 'from' and 'to' as YYYY-MM-DD dates" };
}
const fromDate = parseUtcDate(fromParam);
const toDate = parseUtcDate(toParam);
if (!fromDate || !toDate) {
res.status(400).json({
error: "Invalid from/to: expected YYYY-MM-DD dates",
});
return;
return { error: "Invalid from/to: expected YYYY-MM-DD dates" };
}
if (fromDate.getTime() > toDate.getTime()) {
res.status(400).json({ error: "Invalid from/to: 'from' must be on or before 'to'" });
return;
return { error: "Invalid from/to: 'from' must be on or before 'to'" };
}
const MAX_DAYS = 366;
const days = Math.round((toDate.getTime() - fromDate.getTime()) / DAY_MS) + 1;
if (days > MAX_DAYS) {
res.status(400).json({ error: "Range too large" });
return;
return { error: "Range too large" };
}
rangeStart = fromDate;
rangeEndExclusive = new Date(toDate.getTime() + DAY_MS);
@@ -177,6 +184,35 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
const rangeFromStr = rangeStart.toISOString().slice(0, 10);
const rangeToStr = new Date(rangeEndExclusive.getTime() - DAY_MS).toISOString().slice(0, 10);
return {
window: {
range,
rangeStart,
rangeEndExclusive,
rangeDays,
prevRangeStart,
rangeFromStr,
rangeToStr,
},
};
}
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
const parsed = parseRangeWindow(req.query);
if ("error" in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const {
range,
rangeStart,
rangeEndExclusive,
rangeDays,
prevRangeStart,
rangeFromStr,
rangeToStr,
} = parsed.window;
const [newUsersInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
@@ -391,4 +427,192 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
});
});
const RECENT_OPENS_LIMIT = 100;
router.get(
"/stats/admin/app-opens/by-app/:appId",
requireAuth,
requireAdmin,
async (req, res): Promise<void> => {
const appId = Number(req.params.appId);
if (!Number.isInteger(appId) || appId <= 0) {
res.status(400).json({ error: "Invalid appId" });
return;
}
const parsed = parseRangeWindow(req.query);
if ("error" in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
parsed.window;
const [app] = await db
.select({
id: appsTable.id,
slug: appsTable.slug,
nameAr: appsTable.nameAr,
nameEn: appsTable.nameEn,
iconName: appsTable.iconName,
color: appsTable.color,
})
.from(appsTable)
.where(eq(appsTable.id, appId));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
const [totalRow] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(
and(
eq(appOpensTable.appId, appId),
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
);
const opensRows = await db
.select({
id: appOpensTable.id,
createdAt: appOpensTable.createdAt,
userId: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
})
.from(appOpensTable)
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
.where(
and(
eq(appOpensTable.appId, appId),
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.orderBy(sql`${appOpensTable.createdAt} desc`)
.limit(RECENT_OPENS_LIMIT);
res.json({
appId: app.id,
slug: app.slug,
nameAr: app.nameAr,
nameEn: app.nameEn,
iconName: app.iconName,
color: app.color,
range,
rangeDays,
rangeFrom: rangeFromStr,
rangeTo: rangeToStr,
totalCount: Number(totalRow?.count ?? 0),
opens: opensRows.map((r) => ({
id: r.id,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
userId: r.userId,
username: r.username,
displayNameAr: r.displayNameAr,
displayNameEn: r.displayNameEn,
avatarUrl: r.avatarUrl,
})),
});
},
);
router.get(
"/stats/admin/app-opens/by-user/:userId",
requireAuth,
requireAdmin,
async (req, res): Promise<void> => {
const userId = Number(req.params.userId);
if (!Number.isInteger(userId) || userId <= 0) {
res.status(400).json({ error: "Invalid userId" });
return;
}
const parsed = parseRangeWindow(req.query);
if ("error" in parsed) {
res.status(400).json({ error: parsed.error });
return;
}
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
parsed.window;
const [user] = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
})
.from(usersTable)
.where(eq(usersTable.id, userId));
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const [totalRow] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(
and(
eq(appOpensTable.userId, userId),
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
);
const opensRows = await db
.select({
id: appOpensTable.id,
createdAt: appOpensTable.createdAt,
appId: appsTable.id,
slug: appsTable.slug,
nameAr: appsTable.nameAr,
nameEn: appsTable.nameEn,
iconName: appsTable.iconName,
color: appsTable.color,
})
.from(appOpensTable)
.innerJoin(appsTable, eq(appOpensTable.appId, appsTable.id))
.where(
and(
eq(appOpensTable.userId, userId),
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.orderBy(sql`${appOpensTable.createdAt} desc`)
.limit(RECENT_OPENS_LIMIT);
res.json({
userId: user.id,
username: user.username,
displayNameAr: user.displayNameAr,
displayNameEn: user.displayNameEn,
avatarUrl: user.avatarUrl,
range,
rangeDays,
rangeFrom: rangeFromStr,
rangeTo: rangeToStr,
totalCount: Number(totalRow?.count ?? 0),
opens: opensRows.map((r) => ({
id: r.id,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
appId: r.appId,
slug: r.slug,
nameAr: r.nameAr,
nameEn: r.nameEn,
iconName: r.iconName,
color: r.color,
})),
});
},
);
export default router;
+11 -1
View File
@@ -319,7 +319,17 @@
"leaderboardEmpty": "لا يوجد نشاط بعد",
"openCount": "{{count}} فتحة",
"viewAppDetails": "عرض تفاصيل {{name}}",
"viewUserDetails": "عرض تفاصيل {{name}}"
"viewUserDetails": "عرض تفاصيل {{name}}",
"drillIn": {
"loading": "جارٍ التحميل…",
"appTitle": "آخر الفتحات · {{name}}",
"userTitle": "آخر الفتحات · {{name}}",
"subtitle": "{{count}} فتحة في {{range}}",
"empty": "لا توجد فتحات في هذا النطاق",
"loadError": "تعذّر تحميل آخر الفتحات.",
"editApp": "تعديل التطبيق",
"viewUser": "عرض المستخدم"
}
}
},
"notFound": {
+11 -1
View File
@@ -316,7 +316,17 @@
"leaderboardEmpty": "No activity yet",
"openCount": "{{count}} opens",
"viewAppDetails": "View details for {{name}}",
"viewUserDetails": "View details for {{name}}"
"viewUserDetails": "View details for {{name}}",
"drillIn": {
"loading": "Loading…",
"appTitle": "Recent opens · {{name}}",
"userTitle": "Recent opens · {{name}}",
"subtitle": "{{count}} opens in {{range}}",
"empty": "No opens in this range",
"loadError": "Couldn't load recent opens.",
"editApp": "Edit app",
"viewUser": "View user"
}
}
},
"notFound": {
+292 -4
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
@@ -22,9 +22,21 @@ import {
useUpdateAppSettings,
useGetAdminStats,
getGetAdminStatsQueryKey,
useGetAdminAppOpensByApp,
getGetAdminAppOpensByAppQueryKey,
useGetAdminAppOpensByUser,
getGetAdminAppOpensByUserQueryKey,
useAdminIssueResetLink,
} from "@workspace/api-client-react";
import type { App, Service, UserProfile, AppSettings, AdminStats, GetAdminStatsQueryError } from "@workspace/api-client-react";
import type {
App,
Service,
UserProfile,
AppSettings,
AdminStats,
GetAdminStatsQueryError,
GetAdminStatsParams,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
@@ -514,6 +526,7 @@ export default function AdminPage() {
onCustomFromChange={setCustomFrom}
onCustomToChange={setCustomTo}
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
statsQueryParams={statsQueryParams}
onSelectApp={handleSelectAppFromDashboard}
onSelectUser={handleSelectUserFromDashboard}
/>
@@ -979,6 +992,7 @@ function DashboardSection({
onCustomFromChange,
onCustomToChange,
onApplyCustom,
statsQueryParams,
onSelectApp,
onSelectUser,
}: {
@@ -996,10 +1010,16 @@ function DashboardSection({
onCustomFromChange: (v: string) => void;
onCustomToChange: (v: string) => void;
onApplyCustom: () => void;
statsQueryParams: GetAdminStatsParams;
onSelectApp: (appId: number) => void;
onSelectUser: (userId: number) => void;
}) {
const { t } = useTranslation();
const [drillIn, setDrillIn] = useState<
| { kind: "app"; appId: number }
| { kind: "user"; userId: number }
| null
>(null);
const totalApps = apps?.length ?? 0;
const totalServices = services?.length ?? 0;
@@ -1348,7 +1368,7 @@ function DashboardSection({
<li key={app.appId}>
<button
type="button"
onClick={() => onSelectApp(app.appId)}
onClick={() => setDrillIn({ kind: "app", appId: app.appId })}
aria-label={t("admin.dashboard.viewAppDetails", { name: appName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
@@ -1408,7 +1428,7 @@ function DashboardSection({
<li key={u.userId}>
<button
type="button"
onClick={() => onSelectUser(u.userId)}
onClick={() => setDrillIn({ kind: "user", userId: u.userId })}
aria-label={t("admin.dashboard.viewUserDetails", { name: displayName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
@@ -1483,6 +1503,274 @@ function DashboardSection({
</div>
</div>
</div>
{drillIn?.kind === "app" && (
<AppOpensDrillIn
appId={drillIn.appId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
/>
)}
{drillIn?.kind === "user" && (
<UserOpensDrillIn
userId={drillIn.userId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
/>
)}
</div>
);
}
function DrillInShell({
title,
subtitle,
onClose,
children,
footer,
}: {
title: string;
subtitle: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const { t } = useTranslation();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-[85vh] flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground truncate">{subtitle}</div>
</div>
<button
type="button"
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded-lg hover:bg-slate-100"
aria-label={t("common.close")}
>
{t("common.close")}
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">{children}</div>
{footer && <div className="pt-2 border-t border-slate-200/70">{footer}</div>}
</div>
</div>
);
}
function formatOpenTimestamp(iso: string, lang: string): string {
try {
return formatDateUtil(iso, lang, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function AppOpensDrillIn({
appId,
lang,
rangeLabel,
statsQueryParams,
onClose,
onJump,
}: {
appId: number;
lang: string;
rangeLabel: string;
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (appId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByApp(appId, statsQueryParams, {
query: {
queryKey: getGetAdminAppOpensByAppQueryKey(appId, statsQueryParams),
retry: false,
},
});
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : "";
const title = appName
? t("admin.dashboard.drillIn.appTitle", { name: appName })
: t("admin.dashboard.drillIn.loading");
const subtitle = data
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
: rangeLabel;
return (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(appId)}>
{t("admin.dashboard.drillIn.editApp")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || data.opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<ol className="space-y-1.5">
{data.opens.map((o) => {
const display =
(lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
const initial = (display || o.username || "?").trim().charAt(0).toUpperCase();
const avatar = resolveServiceImageUrl(o.avatarUrl);
return (
<li
key={o.id}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
>
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{display}</div>
{display !== o.username && (
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
)}
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</li>
);
})}
</ol>
)}
</DrillInShell>
);
}
function UserOpensDrillIn({
userId,
lang,
rangeLabel,
statsQueryParams,
onClose,
onJump,
}: {
userId: number;
lang: string;
rangeLabel: string;
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (userId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByUser(userId, statsQueryParams, {
query: {
queryKey: getGetAdminAppOpensByUserQueryKey(userId, statsQueryParams),
retry: false,
},
});
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
const displayName = data
? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username
: "";
const title = displayName
? t("admin.dashboard.drillIn.userTitle", { name: displayName })
: t("admin.dashboard.drillIn.loading");
const subtitle = data
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
: rangeLabel;
return (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(userId)}>
{t("admin.dashboard.drillIn.viewUser")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || data.opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<ol className="space-y-1.5">
{data.opens.map((o) => {
const name = lang === "ar" ? o.nameAr : o.nameEn;
return (
<li
key={o.id}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
>
<div
className="w-8 h-8 rounded-lg shrink-0"
style={{ backgroundColor: `${o.color}40` }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{name}</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</li>
);
})}
</ol>
)}
</DrillInShell>
);
}