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),
+11 -2
View File
@@ -229,12 +229,21 @@
"range": {
"7d": "آخر ٧ أيام",
"30d": "آخر ٣٠ يومًا",
"90d": "آخر ٩٠ يومًا"
"90d": "آخر ٩٠ يومًا",
"custom": "مدى مخصص",
"customLabel": "{{from}} {{to}}"
},
"prevRange": {
"7d": "الأيام ٧ السابقة",
"30d": "الأيام ٣٠ السابقة",
"90d": "الأيام ٩٠ السابقة"
"90d": "الأيام ٩٠ السابقة",
"custom": "الـ {{count}} أيام السابقة"
},
"customRange": {
"from": "من",
"to": "إلى",
"apply": "تطبيق",
"invalid": "اختر نطاقًا صحيحًا"
},
"topApps": "أكثر التطبيقات استخدامًا",
"topAppsSubtitle": "حسب الفتح في {{range}}",
+11 -2
View File
@@ -229,12 +229,21 @@
"range": {
"7d": "Last 7 days",
"30d": "Last 30 days",
"90d": "Last 90 days"
"90d": "Last 90 days",
"custom": "Custom range",
"customLabel": "{{from}} {{to}}"
},
"prevRange": {
"7d": "Previous 7 days",
"30d": "Previous 30 days",
"90d": "Previous 90 days"
"90d": "Previous 90 days",
"custom": "Previous {{count}} days"
},
"customRange": {
"from": "From",
"to": "To",
"apply": "Apply",
"invalid": "Pick a valid date range"
},
"topApps": "Top apps",
"topAppsSubtitle": "By opens in {{range}}",
+135 -35
View File
@@ -83,11 +83,31 @@ export default function AdminPage() {
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } });
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d">("7d");
const { data: adminStats } = useGetAdminStats(
{ range: statsRange },
{ query: { queryKey: getGetAdminStatsQueryKey({ range: statsRange }), enabled: isAdmin } },
);
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d");
const todayIso = new Date().toISOString().slice(0, 10);
const sevenAgoIso = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const [customFrom, setCustomFrom] = useState<string>(sevenAgoIso);
const [customTo, setCustomTo] = useState<string>(todayIso);
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string }>({
from: sevenAgoIso,
to: todayIso,
});
const isCustomValid =
/^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.from) &&
/^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.to) &&
appliedCustom.from <= appliedCustom.to;
const statsQueryParams =
statsRange === "custom"
? { range: "custom" as const, from: appliedCustom.from, to: appliedCustom.to }
: { range: statsRange };
const { data: adminStats } = useGetAdminStats(statsQueryParams, {
query: {
queryKey: getGetAdminStatsQueryKey(statsQueryParams),
enabled: isAdmin && (statsRange !== "custom" || isCustomValid),
},
});
const createApp = useCreateApp();
const updateApp = useUpdateApp();
@@ -399,6 +419,11 @@ export default function AdminPage() {
lang={lang}
statsRange={statsRange}
onStatsRangeChange={setStatsRange}
customFrom={customFrom}
customTo={customTo}
onCustomFromChange={setCustomFrom}
onCustomToChange={setCustomTo}
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
/>
)}
@@ -846,6 +871,11 @@ function DashboardSection({
lang,
statsRange,
onStatsRangeChange,
customFrom,
customTo,
onCustomFromChange,
onCustomToChange,
onApplyCustom,
}: {
apps: App[] | undefined;
services: Service[] | undefined;
@@ -853,8 +883,13 @@ function DashboardSection({
appSettings: AppSettings | undefined;
adminStats: AdminStats | undefined;
lang: string;
statsRange: "7d" | "30d" | "90d";
onStatsRangeChange: (range: "7d" | "30d" | "90d") => void;
statsRange: "7d" | "30d" | "90d" | "custom";
onStatsRangeChange: (range: "7d" | "30d" | "90d" | "custom") => void;
customFrom: string;
customTo: string;
onCustomFromChange: (v: string) => void;
onCustomToChange: (v: string) => void;
onApplyCustom: () => void;
}) {
const { t } = useTranslation();
@@ -912,9 +947,25 @@ function DashboardSection({
const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count));
const mostActiveUsers = adminStats?.mostActiveUsers ?? [];
const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count));
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : 7);
const rangeLabel = t(`admin.dashboard.range.${statsRange}`);
const prevRangeLabel = t(`admin.dashboard.prevRange.${statsRange}`);
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : statsRange === "custom" ? 7 : 7);
const formatRangeDate = (iso: string) => {
try {
return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" });
} catch {
return iso;
}
};
const rangeLabel =
statsRange === "custom" && adminStats?.rangeFrom && adminStats?.rangeTo
? t("admin.dashboard.range.customLabel", {
from: formatRangeDate(adminStats.rangeFrom),
to: formatRangeDate(adminStats.rangeTo),
})
: t(`admin.dashboard.range.${statsRange}`);
const prevRangeLabel =
statsRange === "custom"
? t("admin.dashboard.prevRange.custom", { count: rangeDays })
: t(`admin.dashboard.prevRange.${statsRange}`);
const dayLabel = (iso: string) => {
try {
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
@@ -974,12 +1025,18 @@ function DashboardSection({
</div>
);
const rangeOptions: { value: "7d" | "30d" | "90d"; label: string }[] = [
const rangeOptions: { value: "7d" | "30d" | "90d" | "custom"; label: string }[] = [
{ value: "7d", label: t("admin.dashboard.range.7d") },
{ value: "30d", label: t("admin.dashboard.range.30d") },
{ value: "90d", label: t("admin.dashboard.range.90d") },
{ value: "custom", label: t("admin.dashboard.range.custom") },
];
const customInputsValid =
/^\d{4}-\d{2}-\d{2}$/.test(customFrom) &&
/^\d{4}-\d{2}-\d{2}$/.test(customTo) &&
customFrom <= customTo;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
@@ -996,31 +1053,74 @@ function DashboardSection({
))}
</div>
<div className="glass-panel rounded-2xl p-3 flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.trends")}
</div>
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
{rangeOptions.map((opt) => {
const active = statsRange === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onStatsRangeChange(opt.value)}
aria-pressed={active}
className={cn(
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
active
? "bg-white text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.trends")}
</div>
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
{rangeOptions.map((opt) => {
const active = statsRange === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onStatsRangeChange(opt.value)}
aria-pressed={active}
className={cn(
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
active
? "bg-white text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
</div>
</div>
{statsRange === "custom" && (
<div className="flex items-end gap-2 flex-wrap" dir="ltr">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.from")}
</Label>
<Input
type="date"
value={customFrom}
max={customTo || undefined}
onChange={(e) => onCustomFromChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.to")}
</Label>
<Input
type="date"
value={customTo}
min={customFrom || undefined}
onChange={(e) => onCustomToChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<Button
size="sm"
onClick={onApplyCustom}
disabled={!customInputsValid}
className="h-9"
>
{t("admin.dashboard.customRange.apply")}
</Button>
{!customInputsValid && (
<div className="text-xs text-destructive">
{t("admin.dashboard.customRange.invalid")}
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -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(),