From 44518772442c93cd2dba0e35e20cd5bdfb98a4df Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 20 Apr 2026 15:38:28 +0000 Subject: [PATCH] Let admins pick the time range for dashboard trends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- artifacts/api-server/src/routes/stats.ts | 89 ++++++++-------- artifacts/teaboy-os/public/opengraph.jpg | Bin 25393 -> 25430 bytes artifacts/teaboy-os/src/locales/ar.json | 18 +++- artifacts/teaboy-os/src/locales/en.json | 18 +++- artifacts/teaboy-os/src/pages/admin.tsx | 98 +++++++++++++++--- .../src/generated/api.schemas.ts | 37 ++++++- lib/api-client-react/src/generated/api.ts | 68 +++++++----- lib/api-spec/openapi.yaml | 36 +++++-- lib/api-zod/src/generated/api.ts | 21 +++- 9 files changed, 276 insertions(+), 109 deletions(-) diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index 0a21094e..32f75beb 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -112,26 +112,32 @@ router.get("/stats/home", requireAuth, async (req, res): Promise => { }); }); -router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise => { +router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise => { + 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`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`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`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 }); - } - const buildSeries = ( rows: { day: string; count: number }[], ): { date: string; count: number }[] => { const map = new Map(); 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`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`, + count: sql`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`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`, count: sql`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`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`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`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`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), }); }); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index f0e9e16a8f15f95ce9dc733c0106a90b7c60b36e..502ced98b69229732464001763df84b5a23ca70a 100644 GIT binary patch literal 25430 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 literal 25393 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"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({
{title}
-
{subtitle ?? t("admin.dashboard.last7Days")}
+
{subtitle ?? rangeLabel}
-
- {series.map((s) => { +
+ {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 (
-
{s.count}
+ {showCounts && ( +
{s.count}
+ )}
- {dayLabel(s.date)} + {showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
); @@ -859,6 +887,12 @@ function DashboardSection({
); + 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 (
@@ -875,13 +909,42 @@ function DashboardSection({ ))}
+
+
+ {t("admin.dashboard.trends")} +
+
+ {rangeOptions.map((opt) => { + const active = statsRange === opt.value; + return ( + + ); + })} +
+
+
-
{t("admin.dashboard.newUsers7d")}
+
+ {t("admin.dashboard.newUsersInRange", { range: rangeLabel })} +
{newUsers}
{userDelta !== 0 && ( @@ -899,7 +962,7 @@ function DashboardSection({ )}
- {t("admin.dashboard.vsPrev7d", { count: prevUsers })} + {t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
@@ -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 }), )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index ddb92f63..1e97cdd7 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index cfcdcf61..036c0000 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { - return customFetch(getGetAdminStatsUrl(), { + return customFetch(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>, TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}) => { +>( + params?: GetAdminStatsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey(); + const queryKey = queryOptions?.queryKey ?? getGetAdminStatsQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => getAdminStats({ signal, ...requestOptions }); + }) => getAdminStats(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -3121,15 +3138,18 @@ export type GetAdminStatsQueryError = ErrorType; export function useGetAdminStats< TData = Awaited>, TError = ErrorType, ->(options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - request?: SecondParameter; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getGetAdminStatsQueryOptions(options); +>( + params?: GetAdminStatsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetAdminStatsQueryOptions(params, options); const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 9d0bdae0..ac37bfd6 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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 diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index df67688b..2b2c96e8 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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(), });