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:
riyadhafraa
2026-04-20 15:38:28 +00:00
parent ea77b92120
commit 4451877244
9 changed files with 276 additions and 109 deletions
+43 -46
View File
@@ -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

+17 -1
View File
@@ -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": {
+17 -1
View File
@@ -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": {
+81 -17
View File
@@ -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;
+44 -24
View File
@@ -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
View File
@@ -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
+16 -5
View File
@@ -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(),
});