diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index 13bc9bd6..845f3ac3 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -13,7 +13,7 @@ import { messagesTable, messageReadsTable, } from "@workspace/db"; -import { requireAuth } from "../middlewares/auth"; +import { requireAuth, requireAdmin } from "../middlewares/auth"; const router: IRouter = Router(); @@ -111,4 +111,67 @@ router.get("/stats/home", requireAuth, async (req, res): Promise => { }); }); +router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise => { + const now = new Date(); + const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const sevenDaysAgo = new Date(startOfTodayUtc.getTime() - 6 * 24 * 60 * 60 * 1000); + const fourteenDaysAgo = new Date(startOfTodayUtc.getTime() - 13 * 24 * 60 * 60 * 1000); + const last7Start = sevenDaysAgo; + const prev7Start = fourteenDaysAgo; + + const [newUsersLast7Row] = await db + .select({ count: sql`count(*)` }) + .from(usersTable) + .where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`); + + const [newUsersPrev7Row] = await db + .select({ count: sql`count(*)` }) + .from(usersTable) + .where( + and( + sql`${usersTable.createdAt} >= ${prev7Start.toISOString()}`, + sql`${usersTable.createdAt} < ${last7Start.toISOString()}`, + ), + ); + + const [activeServicesRow] = await db + .select({ count: sql`count(*)` }) + .from(servicesTable) + .where(eq(servicesTable.isAvailable, true)); + + const [inactiveServicesRow] = await db + .select({ count: sql`count(*)` }) + .from(servicesTable) + .where(eq(servicesTable.isAvailable, false)); + + const signupRows = await db + .select({ + day: sql`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`, + count: sql`count(*)`, + }) + .from(usersTable) + .where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`) + .groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`); + + const countsByDay = new Map(); + for (const row of signupRows) { + countsByDay.set(row.day, Number(row.count)); + } + + const signupsByDay: { 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); + signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 }); + } + + res.json({ + newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0), + newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0), + activeServices: Number(activeServicesRow?.count ?? 0), + inactiveServices: Number(inactiveServicesRow?.count ?? 0), + signupsByDay, + }); +}); + export default router; diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 80f7b10a..cd2f5b0a 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -116,7 +116,13 @@ "recentActivity": "آخر النشاطات", "latestUser": "آخر مستخدم", "latestApp": "آخر تطبيق", - "none": "—" + "none": "—", + "newUsers7d": "مستخدمون جدد (٧ أيام)", + "vsPrev7d": "الأسبوع السابق: {{count}}", + "activeServices": "الخدمات النشطة", + "inactiveServices": "غير نشطة: {{count}}", + "signupsTrend": "التسجيلات", + "last7Days": "آخر ٧ أيام" } }, "notFound": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 1ac03dbd..b10796f1 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -116,7 +116,13 @@ "recentActivity": "Recent Activity", "latestUser": "Latest User", "latestApp": "Latest App", - "none": "—" + "none": "—", + "newUsers7d": "New users (7d)", + "vsPrev7d": "Previous 7d: {{count}}", + "activeServices": "Active services", + "inactiveServices": "Inactive: {{count}}", + "signupsTrend": "Sign-ups", + "last7Days": "Last 7 days" } }, "notFound": { diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 037edd2e..18222edf 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -20,13 +20,15 @@ import { useGetAppSettings, getGetAppSettingsQueryKey, useUpdateAppSettings, + useGetAdminStats, + getGetAdminStatsQueryKey, } from "@workspace/api-client-react"; -import type { App, Service, UserProfile, AppSettings } from "@workspace/api-client-react"; +import type { App, Service, UserProfile, AppSettings, AdminStats } 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"; import { resolveServiceImageUrl } from "@/lib/image-url"; -import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon } from "lucide-react"; +import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -79,6 +81,9 @@ export default function AdminPage() { const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } }); const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } }); const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } }); + const { data: adminStats } = useGetAdminStats({ + query: { queryKey: getGetAdminStatsQueryKey(), enabled: isAdmin }, + }); const createApp = useCreateApp(); const updateApp = useUpdateApp(); @@ -384,6 +389,7 @@ export default function AdminPage() { services={services} users={users} appSettings={appSettings} + adminStats={adminStats} lang={lang} /> )} @@ -722,12 +728,14 @@ function DashboardSection({ services, users, appSettings, + adminStats, lang, }: { apps: App[] | undefined; services: Service[] | undefined; users: UserProfile[] | undefined; appSettings: AppSettings | undefined; + adminStats: AdminStats | undefined; lang: string; }) { const { t } = useTranslation(); @@ -770,6 +778,24 @@ function DashboardSection({ }, ]; + const newUsers = adminStats?.newUsersLast7Days ?? 0; + const prevUsers = adminStats?.newUsersPrev7Days ?? 0; + const userDelta = newUsers - prevUsers; + const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null; + const activeServices = adminStats?.activeServices ?? 0; + const inactiveServices = adminStats?.inactiveServices ?? 0; + const signups = adminStats?.signupsByDay ?? []; + const maxSignup = Math.max(1, ...signups.map((s) => s.count)); + const dayLabel = (iso: string) => { + try { + return new Date(iso + "T00:00:00Z").toLocaleDateString(dateLocale, { + weekday: "short", + }); + } catch { + return ""; + } + }; + return (
@@ -786,6 +812,82 @@ function DashboardSection({ ))}
+
+
+
+ +
+
+
{t("admin.dashboard.newUsers7d")}
+
+
{newUsers}
+ {userDelta !== 0 && ( +
0 ? "text-emerald-600" : "text-rose-600", + )} + > + {userDelta > 0 ? : } + {userDeltaPct !== null + ? `${userDelta > 0 ? "+" : ""}${userDeltaPct}%` + : `${userDelta > 0 ? "+" : ""}${userDelta}`} +
+ )} +
+
+ {t("admin.dashboard.vsPrev7d", { count: prevUsers })} +
+
+
+ +
+
+ +
+
+
{t("admin.dashboard.activeServices")}
+
+
{activeServices}
+
+ / {activeServices + inactiveServices} +
+
+
+ {t("admin.dashboard.inactiveServices", { count: inactiveServices })} +
+
+
+
+ +
+
+
+ {t("admin.dashboard.signupsTrend")} +
+
{t("admin.dashboard.last7Days")}
+
+
+ {signups.map((s) => { + const heightPct = (s.count / maxSignup) * 100; + return ( +
+
{s.count}
+
+
0 ? 6 : 2)}%` }} + /> +
+
+ {dayLabel(s.date)} +
+
+ ); + })} +
+
+
{t("admin.dashboard.recentActivity")} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 17e18b9a..0cfaa30d 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -305,3 +305,16 @@ export interface HomeStats { unreadMessages: number; totalUsers: number; } + +export type AdminStatsSignupsByDayItem = { + date: string; + count: number; +}; + +export interface AdminStats { + newUsersLast7Days: number; + newUsersPrev7Days: number; + activeServices: number; + inactiveServices: number; + signupsByDay: AdminStatsSignupsByDayItem[]; +} diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 34009082..51a6d211 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -17,6 +17,7 @@ import type { } from "@tanstack/react-query"; import type { + AdminStats, App, AppSettings, AuthUser, @@ -2977,3 +2978,78 @@ export function useGetHomeStats< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * @summary Get admin dashboard trend stats + */ +export const getGetAdminStatsUrl = () => { + return `/api/stats/admin`; +}; + +export const getAdminStats = async ( + options?: RequestInit, +): Promise => { + return customFetch(getGetAdminStatsUrl(), { + ...options, + method: "GET", + }); +}; + +export const getGetAdminStatsQueryKey = () => { + return [`/api/stats/admin`] as const; +}; + +export const getGetAdminStatsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getAdminStats({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetAdminStatsQueryResult = NonNullable< + Awaited> +>; +export type GetAdminStatsQueryError = ErrorType; + +/** + * @summary Get admin dashboard trend stats + */ + +export function useGetAdminStats< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminStatsQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 6a6a12e4..64286138 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -704,6 +704,19 @@ paths: schema: $ref: "#/components/schemas/HomeStats" + /stats/admin: + get: + operationId: getAdminStats + tags: [stats] + summary: Get admin dashboard trend stats + responses: + "200": + description: Admin stats + content: + application/json: + schema: + $ref: "#/components/schemas/AdminStats" + components: schemas: HealthStatus: @@ -1287,3 +1300,33 @@ components: - unreadNotifications - unreadMessages - totalUsers + + AdminStats: + type: object + properties: + newUsersLast7Days: + type: integer + newUsersPrev7Days: + type: integer + activeServices: + type: integer + inactiveServices: + type: integer + signupsByDay: + type: array + items: + type: object + properties: + date: + type: string + count: + type: integer + required: + - date + - count + required: + - newUsersLast7Days + - newUsersPrev7Days + - activeServices + - inactiveServices + - signupsByDay diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 0fdf5526..37bebe32 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -688,3 +688,19 @@ export const GetHomeStatsResponse = zod.object({ unreadMessages: zod.number(), totalUsers: zod.number(), }); + +/** + * @summary Get admin dashboard trend stats + */ +export const GetAdminStatsResponse = zod.object({ + newUsersLast7Days: zod.number(), + newUsersPrev7Days: zod.number(), + activeServices: zod.number(), + inactiveServices: zod.number(), + signupsByDay: zod.array( + zod.object({ + date: zod.string(), + count: zod.number(), + }), + ), +});