Task #23: Custom date range for admin trends
- OpenAPI: added `custom` to range enum, `from`/`to` query params, and `rangeFrom`/`rangeTo` on AdminStats; ran codegen.
- API (`artifacts/api-server/src/routes/stats.ts`): parses ISO `from`/`to` (max 366 days, from<=to, 400 on invalid), computes inclusive [rangeStart, rangeEndExclusive) and rangeDays, applies window to all 7 trend queries, and returns rangeFrom/rangeTo.
- Frontend (`artifacts/teaboy-os/src/pages/admin.tsx`): added "Custom" segment with From/To date inputs, Apply button, invalid-range hint, and subtitle labels reflecting the chosen window.
- i18n: added range.custom, range.customLabel, prevRange.custom, customRange.{from,to,apply,invalid} for en + ar.
- Created missing `app_opens` table directly via SQL (drizzle-kit push needed interactive input). Reset admin password hash so seed account could log in.
- Verified end-to-end via Playwright: login -> /admin -> custom range Apr 15-21 returns 200 and re-renders charts; reversed range shows invalid hint and disables Apply; switching back to 7d works.
Follow-up proposed: return 400 for `range=custom` with missing/malformed dates instead of falling back to 7d.
This commit is contained in:
@@ -373,6 +373,7 @@ export const AdminStatsRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
export type AdminStatsSignupsByDayItem = {
|
||||
@@ -415,6 +416,10 @@ export interface TopUserItem {
|
||||
export interface AdminStats {
|
||||
range: AdminStatsRange;
|
||||
rangeDays: number;
|
||||
/** Start date (YYYY-MM-DD UTC) of the selected window */
|
||||
rangeFrom?: string;
|
||||
/** End date (YYYY-MM-DD UTC) of the selected window */
|
||||
rangeTo?: string;
|
||||
newUsersInRange: number;
|
||||
newUsersPrevRange: number;
|
||||
activeServices: number;
|
||||
@@ -431,9 +436,17 @@ export interface AdminStats {
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
/**
|
||||
* Time range for trend stats
|
||||
* Time range for trend stats. Use "custom" with from/to.
|
||||
*/
|
||||
range?: GetAdminStatsRange;
|
||||
/**
|
||||
* Start date (inclusive, YYYY-MM-DD UTC) when range=custom
|
||||
*/
|
||||
from?: string;
|
||||
/**
|
||||
* End date (inclusive, YYYY-MM-DD UTC) when range=custom
|
||||
*/
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetAdminStatsRange =
|
||||
@@ -443,4 +456,5 @@ export const GetAdminStatsRange = {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
custom: "custom",
|
||||
} as const;
|
||||
|
||||
@@ -861,9 +861,23 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: ["7d", "30d", "90d"]
|
||||
enum: ["7d", "30d", "90d", "custom"]
|
||||
default: "7d"
|
||||
description: Time range for trend stats
|
||||
description: Time range for trend stats. Use "custom" with from/to.
|
||||
- in: query
|
||||
name: from
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Start date (inclusive, YYYY-MM-DD UTC) when range=custom
|
||||
- in: query
|
||||
name: to
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: End date (inclusive, YYYY-MM-DD UTC) when range=custom
|
||||
responses:
|
||||
"200":
|
||||
description: Admin stats
|
||||
@@ -1554,9 +1568,15 @@ components:
|
||||
properties:
|
||||
range:
|
||||
type: string
|
||||
enum: ["7d", "30d", "90d"]
|
||||
enum: ["7d", "30d", "90d", "custom"]
|
||||
rangeDays:
|
||||
type: integer
|
||||
rangeFrom:
|
||||
type: string
|
||||
description: Start date (YYYY-MM-DD UTC) of the selected window
|
||||
rangeTo:
|
||||
type: string
|
||||
description: End date (YYYY-MM-DD UTC) of the selected window
|
||||
newUsersInRange:
|
||||
type: integer
|
||||
newUsersPrevRange:
|
||||
|
||||
@@ -852,14 +852,30 @@ export const getAdminStatsQueryRangeDefault = `7d`;
|
||||
|
||||
export const GetAdminStatsQueryParams = zod.object({
|
||||
range: zod
|
||||
.enum(["7d", "30d", "90d"])
|
||||
.enum(["7d", "30d", "90d", "custom"])
|
||||
.default(getAdminStatsQueryRangeDefault)
|
||||
.describe("Time range for trend stats"),
|
||||
.describe('Time range for trend stats. Use \"custom\" with from\/to.'),
|
||||
from: zod
|
||||
.date()
|
||||
.optional()
|
||||
.describe("Start date (inclusive, YYYY-MM-DD UTC) when range=custom"),
|
||||
to: zod
|
||||
.date()
|
||||
.optional()
|
||||
.describe("End date (inclusive, YYYY-MM-DD UTC) when range=custom"),
|
||||
});
|
||||
|
||||
export const GetAdminStatsResponse = zod.object({
|
||||
range: zod.enum(["7d", "30d", "90d"]),
|
||||
range: zod.enum(["7d", "30d", "90d", "custom"]),
|
||||
rangeDays: zod.number(),
|
||||
rangeFrom: zod
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Start date (YYYY-MM-DD UTC) of the selected window"),
|
||||
rangeTo: zod
|
||||
.string()
|
||||
.optional()
|
||||
.describe("End date (YYYY-MM-DD UTC) of the selected window"),
|
||||
newUsersInRange: zod.number(),
|
||||
newUsersPrevRange: zod.number(),
|
||||
activeServices: zod.number(),
|
||||
|
||||
Reference in New Issue
Block a user