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