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:
@@ -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;
|
||||
|
||||
+26
-10
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user