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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user