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),
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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