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.
This commit is contained in:
Riyadh
2026-04-20 15:38:28 +00:00
parent ffc645244c
commit 1bd21a85fe
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;