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:
Riyadh
2026-04-21 07:01:43 +00:00
parent 5dae7b3f74
commit e921f2f22b
7 changed files with 311 additions and 61 deletions
@@ -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;
+23 -3
View File
@@ -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:
+19 -3
View File
@@ -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(),