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
@@ -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(),
});