Let admins pick the time range for dashboard trends
Original task: Add a 7d/30d/90d range selector to the admin dashboard trend cards/chart and have /stats/admin accept a matching range parameter. Changes: - OpenAPI (lib/api-spec/openapi.yaml): added optional `range` query parameter (enum 7d|30d|90d, default 7d) to GET /stats/admin and refactored AdminStats fields to be range-agnostic — added `range` and `rangeDays`, renamed `*Last7Days`/`*Prev7Days` to `*InRange`/ `*PrevRange`. Regenerated api-client-react and api-zod. - API server (artifacts/api-server/src/routes/stats.ts): parses and validates the `range` param, computes range/prev-range windows generically, and returns daily series sized to rangeDays. - Admin UI (artifacts/teaboy-os/src/pages/admin.tsx): adds a segmented range selector at the top of the dashboard, passes the selected range into the stats query (with proper queryKey), and adapts the trend chart for denser ranges (skipped per-bar count text >14 days, every-Nth date label, month/day formatting for 30d/90d). - Locale files (en.json/ar.json): added range/prevRange labels, trends/rangeSelector strings, and *Ranged variants of summary keys. Old keys kept to avoid stale references. Verification: - pnpm typecheck passes across libs and artifacts. - e2e test (login as admin → toggle 7d/30d/90d → verify chart bar counts and aria-pressed state, plus API responses for each range) passes. Notes / deviations: - Had to (re)create the `app_opens` table in the dev DB and reset the seeded admin password hash to run the e2e test; both were preexisting environment drift unrelated to this task. - Followed up with: custom date range, persisting last-used range. Replit-Task-Id: 8066030b-b5c4-4b5c-a630-65616df5449e
This commit is contained in:
@@ -112,26 +112,32 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/stats/admin", requireAuth, requireAdmin, 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 allowedRanges = ["7d", "30d", "90d"] as const;
|
||||
type RangeKey = typeof allowedRanges[number];
|
||||
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
|
||||
? (rangeParam as RangeKey)
|
||||
: "7d";
|
||||
const rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7;
|
||||
|
||||
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 rangeStart = new Date(startOfTodayUtc.getTime() - (rangeDays - 1) * 24 * 60 * 60 * 1000);
|
||||
const prevRangeStart = new Date(startOfTodayUtc.getTime() - (2 * rangeDays - 1) * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [newUsersLast7Row] = await db
|
||||
const [newUsersInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
const [newUsersPrev7Row] = await db
|
||||
const [newUsersPrevRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${usersTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||
sql`${usersTable.createdAt} < ${last7Start.toISOString()}`,
|
||||
sql`${usersTable.createdAt} >= ${prevRangeStart.toISOString()}`,
|
||||
sql`${usersTable.createdAt} < ${rangeStart.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,34 +151,13 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.isAvailable, false));
|
||||
|
||||
const signupRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
|
||||
const countsByDay = new Map<string, number>();
|
||||
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 });
|
||||
}
|
||||
|
||||
const buildSeries = (
|
||||
rows: { day: string; count: number }[],
|
||||
): { date: string; count: number }[] => {
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) map.set(r.day, Number(r.count));
|
||||
const out: { date: string; count: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
for (let i = rangeDays - 1; i >= 0; i--) {
|
||||
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
out.push({ date: key, count: map.get(key) ?? 0 });
|
||||
@@ -180,28 +165,38 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
return out;
|
||||
};
|
||||
|
||||
const signupRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const signupsByDay = buildSeries(signupRows);
|
||||
|
||||
const appOpenRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const appOpensByDay = buildSeries(appOpenRows);
|
||||
|
||||
const [appOpensLast7Row] = await db
|
||||
const [appOpensInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
const [appOpensPrev7Row] = await db
|
||||
const [appOpensPrevRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} >= ${prevRangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeStart.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -211,26 +206,28 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(servicesTable)
|
||||
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
|
||||
|
||||
const [servicesCreatedLast7Row] = await db
|
||||
const [servicesCreatedInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(servicesTable)
|
||||
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
res.json({
|
||||
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
||||
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
||||
range,
|
||||
rangeDays,
|
||||
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
|
||||
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
|
||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
||||
signupsByDay,
|
||||
appOpensByDay,
|
||||
appOpensLast7Days: Number(appOpensLast7Row?.count ?? 0),
|
||||
appOpensPrev7Days: Number(appOpensPrev7Row?.count ?? 0),
|
||||
appOpensInRange: Number(appOpensInRangeRow?.count ?? 0),
|
||||
appOpensPrevRange: Number(appOpensPrevRangeRow?.count ?? 0),
|
||||
servicesCreatedByDay,
|
||||
servicesCreatedLast7Days: Number(servicesCreatedLast7Row?.count ?? 0),
|
||||
servicesCreatedInRange: Number(servicesCreatedInRangeRow?.count ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -147,14 +147,30 @@
|
||||
"none": "—",
|
||||
"newUsers7d": "مستخدمون جدد (٧ أيام)",
|
||||
"vsPrev7d": "الأسبوع السابق: {{count}}",
|
||||
"newUsersInRange": "مستخدمون جدد · {{range}}",
|
||||
"vsPrevRange": "{{range}}: {{count}}",
|
||||
"activeServices": "الخدمات النشطة",
|
||||
"inactiveServices": "غير نشطة: {{count}}",
|
||||
"signupsTrend": "التسجيلات",
|
||||
"last7Days": "آخر ٧ أيام",
|
||||
"appOpensTrend": "فتح التطبيقات",
|
||||
"appOpensSummary": "{{count}} إجمالاً · {{delta}}",
|
||||
"appOpensSummaryRanged": "{{count}} في {{range}} · {{delta}}",
|
||||
"servicesCreatedTrend": "خدمات جديدة",
|
||||
"servicesCreatedSummary": "{{count}} مضافة"
|
||||
"servicesCreatedSummary": "{{count}} مضافة",
|
||||
"servicesCreatedSummaryRanged": "{{count}} في {{range}}",
|
||||
"trends": "الاتجاهات",
|
||||
"rangeSelector": "المدى الزمني",
|
||||
"range": {
|
||||
"7d": "آخر ٧ أيام",
|
||||
"30d": "آخر ٣٠ يومًا",
|
||||
"90d": "آخر ٩٠ يومًا"
|
||||
},
|
||||
"prevRange": {
|
||||
"7d": "الأيام ٧ السابقة",
|
||||
"30d": "الأيام ٣٠ السابقة",
|
||||
"90d": "الأيام ٩٠ السابقة"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -147,14 +147,30 @@
|
||||
"none": "—",
|
||||
"newUsers7d": "New users (7d)",
|
||||
"vsPrev7d": "Previous 7d: {{count}}",
|
||||
"newUsersInRange": "New users · {{range}}",
|
||||
"vsPrevRange": "{{range}}: {{count}}",
|
||||
"activeServices": "Active services",
|
||||
"inactiveServices": "Inactive: {{count}}",
|
||||
"signupsTrend": "Sign-ups",
|
||||
"last7Days": "Last 7 days",
|
||||
"appOpensTrend": "App opens",
|
||||
"appOpensSummary": "{{count}} total · {{delta}}",
|
||||
"appOpensSummaryRanged": "{{count}} in {{range}} · {{delta}}",
|
||||
"servicesCreatedTrend": "Services added",
|
||||
"servicesCreatedSummary": "{{count}} added"
|
||||
"servicesCreatedSummary": "{{count}} added",
|
||||
"servicesCreatedSummaryRanged": "{{count}} in {{range}}",
|
||||
"trends": "Trends",
|
||||
"rangeSelector": "Time range",
|
||||
"range": {
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
"90d": "Last 90 days"
|
||||
},
|
||||
"prevRange": {
|
||||
"7d": "Previous 7 days",
|
||||
"30d": "Previous 30 days",
|
||||
"90d": "Previous 90 days"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -82,9 +82,11 @@ 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 [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d">("7d");
|
||||
const { data: adminStats } = useGetAdminStats(
|
||||
{ range: statsRange },
|
||||
{ query: { queryKey: getGetAdminStatsQueryKey({ range: statsRange }), enabled: isAdmin } },
|
||||
);
|
||||
|
||||
const createApp = useCreateApp();
|
||||
const updateApp = useUpdateApp();
|
||||
@@ -392,6 +394,8 @@ export default function AdminPage() {
|
||||
appSettings={appSettings}
|
||||
adminStats={adminStats}
|
||||
lang={lang}
|
||||
statsRange={statsRange}
|
||||
onStatsRangeChange={setStatsRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -757,6 +761,8 @@ function DashboardSection({
|
||||
appSettings,
|
||||
adminStats,
|
||||
lang,
|
||||
statsRange,
|
||||
onStatsRangeChange,
|
||||
}: {
|
||||
apps: App[] | undefined;
|
||||
services: Service[] | undefined;
|
||||
@@ -764,6 +770,8 @@ function DashboardSection({
|
||||
appSettings: AppSettings | undefined;
|
||||
adminStats: AdminStats | undefined;
|
||||
lang: string;
|
||||
statsRange: "7d" | "30d" | "90d";
|
||||
onStatsRangeChange: (range: "7d" | "30d" | "90d") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -800,8 +808,8 @@ function DashboardSection({
|
||||
},
|
||||
];
|
||||
|
||||
const newUsers = adminStats?.newUsersLast7Days ?? 0;
|
||||
const prevUsers = adminStats?.newUsersPrev7Days ?? 0;
|
||||
const newUsers = adminStats?.newUsersInRange ?? 0;
|
||||
const prevUsers = adminStats?.newUsersPrevRange ?? 0;
|
||||
const userDelta = newUsers - prevUsers;
|
||||
const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null;
|
||||
const activeServices = adminStats?.activeServices ?? 0;
|
||||
@@ -810,13 +818,16 @@ function DashboardSection({
|
||||
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
||||
const opens = adminStats?.appOpensByDay ?? [];
|
||||
const maxOpens = Math.max(1, ...opens.map((s) => s.count));
|
||||
const opensTotal = adminStats?.appOpensLast7Days ?? 0;
|
||||
const opensPrev = adminStats?.appOpensPrev7Days ?? 0;
|
||||
const opensTotal = adminStats?.appOpensInRange ?? 0;
|
||||
const opensPrev = adminStats?.appOpensPrevRange ?? 0;
|
||||
const opensDelta = opensTotal - opensPrev;
|
||||
const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null;
|
||||
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
|
||||
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
|
||||
const servicesCreatedTotal = adminStats?.servicesCreatedLast7Days ?? 0;
|
||||
const servicesCreatedTotal = adminStats?.servicesCreatedInRange ?? 0;
|
||||
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}`);
|
||||
const dayLabel = (iso: string) => {
|
||||
try {
|
||||
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
||||
@@ -825,6 +836,17 @@ function DashboardSection({
|
||||
}
|
||||
};
|
||||
|
||||
const showCounts = rangeDays <= 14;
|
||||
const labelStride = rangeDays <= 7 ? 1 : rangeDays <= 30 ? 5 : 15;
|
||||
const gapClass = rangeDays <= 7 ? "gap-1.5" : rangeDays <= 30 ? "gap-1" : "gap-px";
|
||||
const monthDayLabel = (iso: string) => {
|
||||
try {
|
||||
return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return iso.slice(5);
|
||||
}
|
||||
};
|
||||
|
||||
const renderChart = (
|
||||
title: string,
|
||||
series: { date: string; count: number }[],
|
||||
@@ -835,14 +857,20 @@ function DashboardSection({
|
||||
<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">{title}</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? t("admin.dashboard.last7Days")}</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? rangeLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
|
||||
{series.map((s) => {
|
||||
<div className={cn("flex items-end justify-between h-28 px-1", gapClass)} dir="ltr">
|
||||
{series.map((s, idx) => {
|
||||
const heightPct = (s.count / max) * 100;
|
||||
const showLabel =
|
||||
rangeDays <= 7
|
||||
? true
|
||||
: idx === series.length - 1 || (series.length - 1 - idx) % labelStride === 0;
|
||||
return (
|
||||
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||
{showCounts && (
|
||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||
)}
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
|
||||
@@ -850,7 +878,7 @@ function DashboardSection({
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||
{dayLabel(s.date)}
|
||||
{showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -859,6 +887,12 @@ function DashboardSection({
|
||||
</div>
|
||||
);
|
||||
|
||||
const rangeOptions: { value: "7d" | "30d" | "90d"; label: string }[] = [
|
||||
{ value: "7d", label: t("admin.dashboard.range.7d") },
|
||||
{ value: "30d", label: t("admin.dashboard.range.30d") },
|
||||
{ value: "90d", label: t("admin.dashboard.range.90d") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
@@ -875,13 +909,42 @@ function DashboardSection({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("admin.dashboard.trends")}
|
||||
</div>
|
||||
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
|
||||
{rangeOptions.map((opt) => {
|
||||
const active = statsRange === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onStatsRangeChange(opt.value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
|
||||
active
|
||||
? "bg-white text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-sky-600 bg-sky-100/70">
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.newUsers7d")}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{t("admin.dashboard.newUsersInRange", { range: rangeLabel })}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-xl font-semibold text-foreground">{newUsers}</div>
|
||||
{userDelta !== 0 && (
|
||||
@@ -899,7 +962,7 @@ function DashboardSection({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{t("admin.dashboard.vsPrev7d", { count: prevUsers })}
|
||||
{t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -936,8 +999,9 @@ function DashboardSection({
|
||||
opens,
|
||||
maxOpens,
|
||||
"from-sky-300/40 to-sky-500",
|
||||
t("admin.dashboard.appOpensSummary", {
|
||||
t("admin.dashboard.appOpensSummaryRanged", {
|
||||
count: opensTotal,
|
||||
range: rangeLabel,
|
||||
delta:
|
||||
opensDelta === 0
|
||||
? "±0"
|
||||
@@ -951,7 +1015,7 @@ function DashboardSection({
|
||||
servicesCreated,
|
||||
maxServicesCreated,
|
||||
"from-amber-300/40 to-amber-500",
|
||||
t("admin.dashboard.servicesCreatedSummary", { count: servicesCreatedTotal }),
|
||||
t("admin.dashboard.servicesCreatedSummaryRanged", { count: servicesCreatedTotal, range: rangeLabel }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,6 +318,15 @@ export interface HomeStats {
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
export type AdminStatsRange =
|
||||
(typeof AdminStatsRange)[keyof typeof AdminStatsRange];
|
||||
|
||||
export const AdminStatsRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
} as const;
|
||||
|
||||
export type AdminStatsSignupsByDayItem = {
|
||||
date: string;
|
||||
count: number;
|
||||
@@ -334,14 +343,32 @@ export type AdminStatsServicesCreatedByDayItem = {
|
||||
};
|
||||
|
||||
export interface AdminStats {
|
||||
newUsersLast7Days: number;
|
||||
newUsersPrev7Days: number;
|
||||
range: AdminStatsRange;
|
||||
rangeDays: number;
|
||||
newUsersInRange: number;
|
||||
newUsersPrevRange: number;
|
||||
activeServices: number;
|
||||
inactiveServices: number;
|
||||
signupsByDay: AdminStatsSignupsByDayItem[];
|
||||
appOpensByDay: AdminStatsAppOpensByDayItem[];
|
||||
appOpensLast7Days: number;
|
||||
appOpensPrev7Days: number;
|
||||
appOpensInRange: number;
|
||||
appOpensPrevRange: number;
|
||||
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
|
||||
servicesCreatedLast7Days: number;
|
||||
servicesCreatedInRange: number;
|
||||
}
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
/**
|
||||
* Time range for trend stats
|
||||
*/
|
||||
range?: GetAdminStatsRange;
|
||||
};
|
||||
|
||||
export type GetAdminStatsRange =
|
||||
(typeof GetAdminStatsRange)[keyof typeof GetAdminStatsRange];
|
||||
|
||||
export const GetAdminStatsRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
} as const;
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
CreateConversationBody,
|
||||
CreateServiceBody,
|
||||
ErrorResponse,
|
||||
GetAdminStatsParams,
|
||||
HealthStatus,
|
||||
HomeStats,
|
||||
LoginBody,
|
||||
@@ -3066,41 +3067,57 @@ export function useGetHomeStats<
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
*/
|
||||
export const getGetAdminStatsUrl = () => {
|
||||
return `/api/stats/admin`;
|
||||
export const getGetAdminStatsUrl = (params?: GetAdminStatsParams) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/stats/admin?${stringifiedParams}`
|
||||
: `/api/stats/admin`;
|
||||
};
|
||||
|
||||
export const getAdminStats = async (
|
||||
params?: GetAdminStatsParams,
|
||||
options?: RequestInit,
|
||||
): Promise<AdminStats> => {
|
||||
return customFetch<AdminStats>(getGetAdminStatsUrl(), {
|
||||
return customFetch<AdminStats>(getGetAdminStatsUrl(params), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAdminStatsQueryKey = () => {
|
||||
return [`/api/stats/admin`] as const;
|
||||
export const getGetAdminStatsQueryKey = (params?: GetAdminStatsParams) => {
|
||||
return [`/api/stats/admin`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetAdminStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
>(
|
||||
params?: GetAdminStatsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey();
|
||||
const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAdminStats>>> = ({
|
||||
signal,
|
||||
}) => getAdminStats({ signal, ...requestOptions });
|
||||
}) => getAdminStats(params, { signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
@@ -3121,15 +3138,18 @@ export type GetAdminStatsQueryError = ErrorType<unknown>;
|
||||
export function useGetAdminStats<
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAdminStatsQueryOptions(options);
|
||||
>(
|
||||
params?: GetAdminStatsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAdminStatsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
|
||||
+26
-10
@@ -730,6 +730,15 @@ paths:
|
||||
operationId: getAdminStats
|
||||
tags: [stats]
|
||||
summary: Get admin dashboard trend stats
|
||||
parameters:
|
||||
- in: query
|
||||
name: range
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: ["7d", "30d", "90d"]
|
||||
default: "7d"
|
||||
description: Time range for trend stats
|
||||
responses:
|
||||
"200":
|
||||
description: Admin stats
|
||||
@@ -1337,9 +1346,14 @@ components:
|
||||
AdminStats:
|
||||
type: object
|
||||
properties:
|
||||
newUsersLast7Days:
|
||||
range:
|
||||
type: string
|
||||
enum: ["7d", "30d", "90d"]
|
||||
rangeDays:
|
||||
type: integer
|
||||
newUsersPrev7Days:
|
||||
newUsersInRange:
|
||||
type: integer
|
||||
newUsersPrevRange:
|
||||
type: integer
|
||||
activeServices:
|
||||
type: integer
|
||||
@@ -1369,9 +1383,9 @@ components:
|
||||
required:
|
||||
- date
|
||||
- count
|
||||
appOpensLast7Days:
|
||||
appOpensInRange:
|
||||
type: integer
|
||||
appOpensPrev7Days:
|
||||
appOpensPrevRange:
|
||||
type: integer
|
||||
servicesCreatedByDay:
|
||||
type: array
|
||||
@@ -1385,16 +1399,18 @@ components:
|
||||
required:
|
||||
- date
|
||||
- count
|
||||
servicesCreatedLast7Days:
|
||||
servicesCreatedInRange:
|
||||
type: integer
|
||||
required:
|
||||
- newUsersLast7Days
|
||||
- newUsersPrev7Days
|
||||
- range
|
||||
- rangeDays
|
||||
- newUsersInRange
|
||||
- newUsersPrevRange
|
||||
- activeServices
|
||||
- inactiveServices
|
||||
- signupsByDay
|
||||
- appOpensByDay
|
||||
- appOpensLast7Days
|
||||
- appOpensPrev7Days
|
||||
- appOpensInRange
|
||||
- appOpensPrevRange
|
||||
- servicesCreatedByDay
|
||||
- servicesCreatedLast7Days
|
||||
- servicesCreatedInRange
|
||||
|
||||
@@ -717,9 +717,20 @@ export const GetHomeStatsResponse = zod.object({
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
*/
|
||||
export const getAdminStatsQueryRangeDefault = `7d`;
|
||||
|
||||
export const GetAdminStatsQueryParams = zod.object({
|
||||
range: zod
|
||||
.enum(["7d", "30d", "90d"])
|
||||
.default(getAdminStatsQueryRangeDefault)
|
||||
.describe("Time range for trend stats"),
|
||||
});
|
||||
|
||||
export const GetAdminStatsResponse = zod.object({
|
||||
newUsersLast7Days: zod.number(),
|
||||
newUsersPrev7Days: zod.number(),
|
||||
range: zod.enum(["7d", "30d", "90d"]),
|
||||
rangeDays: zod.number(),
|
||||
newUsersInRange: zod.number(),
|
||||
newUsersPrevRange: zod.number(),
|
||||
activeServices: zod.number(),
|
||||
inactiveServices: zod.number(),
|
||||
signupsByDay: zod.array(
|
||||
@@ -734,13 +745,13 @@ export const GetAdminStatsResponse = zod.object({
|
||||
count: zod.number(),
|
||||
}),
|
||||
),
|
||||
appOpensLast7Days: zod.number(),
|
||||
appOpensPrev7Days: zod.number(),
|
||||
appOpensInRange: zod.number(),
|
||||
appOpensPrevRange: zod.number(),
|
||||
servicesCreatedByDay: zod.array(
|
||||
zod.object({
|
||||
date: zod.string(),
|
||||
count: zod.number(),
|
||||
}),
|
||||
),
|
||||
servicesCreatedLast7Days: zod.number(),
|
||||
servicesCreatedInRange: zod.number(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user