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
@@ -484,6 +484,82 @@ export interface AdminStats {
mostActiveUsers: TopUserItem[];
}
export interface AppOpenByAppEntry {
id: number;
createdAt: string;
userId: number;
username: string;
/** @nullable */
displayNameAr?: string | null;
/** @nullable */
displayNameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
}
export type AdminAppOpensByAppRange =
(typeof AdminAppOpensByAppRange)[keyof typeof AdminAppOpensByAppRange];
export const AdminAppOpensByAppRange = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
custom: "custom",
} as const;
export interface AdminAppOpensByApp {
appId: number;
slug: string;
nameAr: string;
nameEn: string;
iconName: string;
color: string;
range: AdminAppOpensByAppRange;
rangeDays: number;
rangeFrom: string;
rangeTo: string;
totalCount: number;
opens: AppOpenByAppEntry[];
}
export interface AppOpenByUserEntry {
id: number;
createdAt: string;
appId: number;
slug: string;
nameAr: string;
nameEn: string;
iconName: string;
color: string;
}
export type AdminAppOpensByUserRange =
(typeof AdminAppOpensByUserRange)[keyof typeof AdminAppOpensByUserRange];
export const AdminAppOpensByUserRange = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
custom: "custom",
} as const;
export interface AdminAppOpensByUser {
userId: number;
username: string;
/** @nullable */
displayNameAr?: string | null;
/** @nullable */
displayNameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
range: AdminAppOpensByUserRange;
rangeDays: number;
rangeFrom: string;
rangeTo: string;
totalCount: number;
opens: AppOpenByUserEntry[];
}
export type GetAdminStatsParams = {
/**
* Time range for trend stats. Use "custom" with from/to.
@@ -508,3 +584,35 @@ export const GetAdminStatsRange = {
"90d": "90d",
custom: "custom",
} as const;
export type GetAdminAppOpensByAppParams = {
range?: GetAdminAppOpensByAppRange;
from?: string;
to?: string;
};
export type GetAdminAppOpensByAppRange =
(typeof GetAdminAppOpensByAppRange)[keyof typeof GetAdminAppOpensByAppRange];
export const GetAdminAppOpensByAppRange = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
custom: "custom",
} as const;
export type GetAdminAppOpensByUserParams = {
range?: GetAdminAppOpensByUserRange;
from?: string;
to?: string;
};
export type GetAdminAppOpensByUserRange =
(typeof GetAdminAppOpensByUserRange)[keyof typeof GetAdminAppOpensByUserRange];
export const GetAdminAppOpensByUserRange = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
custom: "custom",
} as const;