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.

Replit-Task-Id: a50d8a1e-60ad-43b2-b8ea-4eeae6ef5dd0
This commit is contained in:
riyadhafraa
2026-04-21 07:01:43 +00:00
parent b6219e9bbe
commit 1c82edf5a9
7 changed files with 311 additions and 61 deletions
+97 -15
View File
@@ -114,22 +114,67 @@ router.get("/stats/home", requireAuth, async (req, res): Promise<void> => {
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
const rangeParam = typeof req.query.range === "string" ? req.query.range : "7d";
const allowedRanges = ["7d", "30d", "90d"] as const;
const fromParam = typeof req.query.from === "string" ? req.query.from : undefined;
const toParam = typeof req.query.to === "string" ? req.query.to : undefined;
const allowedRanges = ["7d", "30d", "90d", "custom"] as const;
type RangeKey = typeof allowedRanges[number];
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
let range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
? (rangeParam as RangeKey)
: "7d";
const rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7;
const DAY_MS = 24 * 60 * 60 * 1000;
const isoDateRe = /^\d{4}-\d{2}-\d{2}$/;
const parseUtcDate = (s: string): Date | null => {
if (!isoDateRe.test(s)) return null;
const d = new Date(s + "T00:00:00.000Z");
if (Number.isNaN(d.getTime())) return null;
if (d.toISOString().slice(0, 10) !== s) return null;
return d;
};
const now = new Date();
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const rangeStart = new Date(startOfTodayUtc.getTime() - (rangeDays - 1) * 24 * 60 * 60 * 1000);
const prevRangeStart = new Date(startOfTodayUtc.getTime() - (2 * rangeDays - 1) * 24 * 60 * 60 * 1000);
let rangeStart: Date;
let rangeEndExclusive: Date;
let rangeDays: number;
if (range === "custom" && fromParam && toParam) {
const fromDate = parseUtcDate(fromParam);
const toDate = parseUtcDate(toParam);
if (!fromDate || !toDate || fromDate.getTime() > toDate.getTime()) {
res.status(400).json({ error: "Invalid from/to" });
return;
}
const MAX_DAYS = 366;
const days = Math.round((toDate.getTime() - fromDate.getTime()) / DAY_MS) + 1;
if (days > MAX_DAYS) {
res.status(400).json({ error: "Range too large" });
return;
}
rangeStart = fromDate;
rangeEndExclusive = new Date(toDate.getTime() + DAY_MS);
rangeDays = days;
} else {
if (range === "custom") range = "7d";
rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7;
rangeStart = new Date(startOfTodayUtc.getTime() - (rangeDays - 1) * DAY_MS);
rangeEndExclusive = new Date(startOfTodayUtc.getTime() + DAY_MS);
}
const prevRangeStart = new Date(rangeStart.getTime() - rangeDays * DAY_MS);
const rangeFromStr = rangeStart.toISOString().slice(0, 10);
const rangeToStr = new Date(rangeEndExclusive.getTime() - DAY_MS).toISOString().slice(0, 10);
const [newUsersInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`);
.where(
and(
sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${usersTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
);
const [newUsersPrevRangeRow] = await db
.select({ count: sql<number>`count(*)` })
@@ -157,8 +202,8 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
const map = new Map<string, number>();
for (const r of rows) map.set(r.day, Number(r.count));
const out: { date: string; count: number }[] = [];
for (let i = rangeDays - 1; i >= 0; i--) {
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
for (let i = 0; i < rangeDays; i++) {
const d = new Date(rangeStart.getTime() + i * DAY_MS);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, count: map.get(key) ?? 0 });
}
@@ -171,7 +216,12 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
count: sql<number>`count(*)`,
})
.from(usersTable)
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`)
.where(
and(
sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${usersTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
const signupsByDay = buildSeries(signupRows);
@@ -181,14 +231,24 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
count: sql<number>`count(*)`,
})
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
.where(
and(
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
const appOpensByDay = buildSeries(appOpenRows);
const [appOpensInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`);
.where(
and(
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
);
const [appOpensPrevRangeRow] = await db
.select({ count: sql<number>`count(*)` })
@@ -206,14 +266,24 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
count: sql<number>`count(*)`,
})
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`)
.where(
and(
sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${servicesTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
const [servicesCreatedInRangeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
.where(
and(
sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${servicesTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
);
const topAppsRows = await db
.select({
@@ -227,7 +297,12 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
})
.from(appOpensTable)
.innerJoin(appsTable, eq(appOpensTable.appId, appsTable.id))
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
.where(
and(
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.groupBy(
appsTable.id,
appsTable.slug,
@@ -260,7 +335,12 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
})
.from(appOpensTable)
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
.where(
and(
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
),
)
.groupBy(
usersTable.id,
usersTable.username,
@@ -283,6 +363,8 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
res.json({
range,
rangeDays,
rangeFrom: rangeFromStr,
rangeTo: rangeToStr,
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
activeServices: Number(activeServicesRow?.count ?? 0),