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> => {
|
router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<void> => {
|
||||||
const rangeParam = typeof req.query.range === "string" ? req.query.range : "7d";
|
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];
|
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)
|
? (rangeParam as RangeKey)
|
||||||
: "7d";
|
: "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 now = new Date();
|
||||||
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
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
|
const [newUsersInRangeRow] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`);
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||||
|
sql`${usersTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const [newUsersPrevRangeRow] = await db
|
const [newUsersPrevRangeRow] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.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>();
|
const map = new Map<string, number>();
|
||||||
for (const r of rows) map.set(r.day, Number(r.count));
|
for (const r of rows) map.set(r.day, Number(r.count));
|
||||||
const out: { date: string; count: number }[] = [];
|
const out: { date: string; count: number }[] = [];
|
||||||
for (let i = rangeDays - 1; i >= 0; i--) {
|
for (let i = 0; i < rangeDays; i++) {
|
||||||
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
const d = new Date(rangeStart.getTime() + i * DAY_MS);
|
||||||
const key = d.toISOString().slice(0, 10);
|
const key = d.toISOString().slice(0, 10);
|
||||||
out.push({ date: key, count: map.get(key) ?? 0 });
|
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(*)`,
|
count: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.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')`);
|
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||||
const signupsByDay = buildSeries(signupRows);
|
const signupsByDay = buildSeries(signupRows);
|
||||||
|
|
||||||
@@ -181,14 +231,24 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
|||||||
count: sql<number>`count(*)`,
|
count: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(appOpensTable)
|
.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')`);
|
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||||
const appOpensByDay = buildSeries(appOpenRows);
|
const appOpensByDay = buildSeries(appOpenRows);
|
||||||
|
|
||||||
const [appOpensInRangeRow] = await db
|
const [appOpensInRangeRow] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(appOpensTable)
|
.from(appOpensTable)
|
||||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`);
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||||
|
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const [appOpensPrevRangeRow] = await db
|
const [appOpensPrevRangeRow] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -206,14 +266,24 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
|||||||
count: sql<number>`count(*)`,
|
count: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(servicesTable)
|
.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')`);
|
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||||
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
|
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
|
||||||
|
|
||||||
const [servicesCreatedInRangeRow] = await db
|
const [servicesCreatedInRangeRow] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(servicesTable)
|
.from(servicesTable)
|
||||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`,
|
||||||
|
sql`${servicesTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const topAppsRows = await db
|
const topAppsRows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -227,7 +297,12 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
|||||||
})
|
})
|
||||||
.from(appOpensTable)
|
.from(appOpensTable)
|
||||||
.innerJoin(appsTable, eq(appOpensTable.appId, appsTable.id))
|
.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(
|
.groupBy(
|
||||||
appsTable.id,
|
appsTable.id,
|
||||||
appsTable.slug,
|
appsTable.slug,
|
||||||
@@ -260,7 +335,12 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
|||||||
})
|
})
|
||||||
.from(appOpensTable)
|
.from(appOpensTable)
|
||||||
.innerJoin(usersTable, eq(appOpensTable.userId, usersTable.id))
|
.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(
|
.groupBy(
|
||||||
usersTable.id,
|
usersTable.id,
|
||||||
usersTable.username,
|
usersTable.username,
|
||||||
@@ -283,6 +363,8 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
|||||||
res.json({
|
res.json({
|
||||||
range,
|
range,
|
||||||
rangeDays,
|
rangeDays,
|
||||||
|
rangeFrom: rangeFromStr,
|
||||||
|
rangeTo: rangeToStr,
|
||||||
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
|
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
|
||||||
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
|
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
|
||||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||||
|
|||||||
@@ -229,12 +229,21 @@
|
|||||||
"range": {
|
"range": {
|
||||||
"7d": "آخر ٧ أيام",
|
"7d": "آخر ٧ أيام",
|
||||||
"30d": "آخر ٣٠ يومًا",
|
"30d": "آخر ٣٠ يومًا",
|
||||||
"90d": "آخر ٩٠ يومًا"
|
"90d": "آخر ٩٠ يومًا",
|
||||||
|
"custom": "مدى مخصص",
|
||||||
|
"customLabel": "{{from}} – {{to}}"
|
||||||
},
|
},
|
||||||
"prevRange": {
|
"prevRange": {
|
||||||
"7d": "الأيام ٧ السابقة",
|
"7d": "الأيام ٧ السابقة",
|
||||||
"30d": "الأيام ٣٠ السابقة",
|
"30d": "الأيام ٣٠ السابقة",
|
||||||
"90d": "الأيام ٩٠ السابقة"
|
"90d": "الأيام ٩٠ السابقة",
|
||||||
|
"custom": "الـ {{count}} أيام السابقة"
|
||||||
|
},
|
||||||
|
"customRange": {
|
||||||
|
"from": "من",
|
||||||
|
"to": "إلى",
|
||||||
|
"apply": "تطبيق",
|
||||||
|
"invalid": "اختر نطاقًا صحيحًا"
|
||||||
},
|
},
|
||||||
"topApps": "أكثر التطبيقات استخدامًا",
|
"topApps": "أكثر التطبيقات استخدامًا",
|
||||||
"topAppsSubtitle": "حسب الفتح في {{range}}",
|
"topAppsSubtitle": "حسب الفتح في {{range}}",
|
||||||
|
|||||||
@@ -229,12 +229,21 @@
|
|||||||
"range": {
|
"range": {
|
||||||
"7d": "Last 7 days",
|
"7d": "Last 7 days",
|
||||||
"30d": "Last 30 days",
|
"30d": "Last 30 days",
|
||||||
"90d": "Last 90 days"
|
"90d": "Last 90 days",
|
||||||
|
"custom": "Custom range",
|
||||||
|
"customLabel": "{{from}} – {{to}}"
|
||||||
},
|
},
|
||||||
"prevRange": {
|
"prevRange": {
|
||||||
"7d": "Previous 7 days",
|
"7d": "Previous 7 days",
|
||||||
"30d": "Previous 30 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",
|
"topApps": "Top apps",
|
||||||
"topAppsSubtitle": "By opens in {{range}}",
|
"topAppsSubtitle": "By opens in {{range}}",
|
||||||
|
|||||||
@@ -83,11 +83,31 @@ export default function AdminPage() {
|
|||||||
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
||||||
const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } });
|
const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } });
|
||||||
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||||
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d">("7d");
|
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d");
|
||||||
const { data: adminStats } = useGetAdminStats(
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
{ range: statsRange },
|
const sevenAgoIso = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000)
|
||||||
{ query: { queryKey: getGetAdminStatsQueryKey({ range: statsRange }), enabled: isAdmin } },
|
.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 createApp = useCreateApp();
|
||||||
const updateApp = useUpdateApp();
|
const updateApp = useUpdateApp();
|
||||||
@@ -399,6 +419,11 @@ export default function AdminPage() {
|
|||||||
lang={lang}
|
lang={lang}
|
||||||
statsRange={statsRange}
|
statsRange={statsRange}
|
||||||
onStatsRangeChange={setStatsRange}
|
onStatsRangeChange={setStatsRange}
|
||||||
|
customFrom={customFrom}
|
||||||
|
customTo={customTo}
|
||||||
|
onCustomFromChange={setCustomFrom}
|
||||||
|
onCustomToChange={setCustomTo}
|
||||||
|
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -846,6 +871,11 @@ function DashboardSection({
|
|||||||
lang,
|
lang,
|
||||||
statsRange,
|
statsRange,
|
||||||
onStatsRangeChange,
|
onStatsRangeChange,
|
||||||
|
customFrom,
|
||||||
|
customTo,
|
||||||
|
onCustomFromChange,
|
||||||
|
onCustomToChange,
|
||||||
|
onApplyCustom,
|
||||||
}: {
|
}: {
|
||||||
apps: App[] | undefined;
|
apps: App[] | undefined;
|
||||||
services: Service[] | undefined;
|
services: Service[] | undefined;
|
||||||
@@ -853,8 +883,13 @@ function DashboardSection({
|
|||||||
appSettings: AppSettings | undefined;
|
appSettings: AppSettings | undefined;
|
||||||
adminStats: AdminStats | undefined;
|
adminStats: AdminStats | undefined;
|
||||||
lang: string;
|
lang: string;
|
||||||
statsRange: "7d" | "30d" | "90d";
|
statsRange: "7d" | "30d" | "90d" | "custom";
|
||||||
onStatsRangeChange: (range: "7d" | "30d" | "90d") => void;
|
onStatsRangeChange: (range: "7d" | "30d" | "90d" | "custom") => void;
|
||||||
|
customFrom: string;
|
||||||
|
customTo: string;
|
||||||
|
onCustomFromChange: (v: string) => void;
|
||||||
|
onCustomToChange: (v: string) => void;
|
||||||
|
onApplyCustom: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -912,9 +947,25 @@ function DashboardSection({
|
|||||||
const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count));
|
const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count));
|
||||||
const mostActiveUsers = adminStats?.mostActiveUsers ?? [];
|
const mostActiveUsers = adminStats?.mostActiveUsers ?? [];
|
||||||
const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count));
|
const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count));
|
||||||
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : 7);
|
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : statsRange === "custom" ? 7 : 7);
|
||||||
const rangeLabel = t(`admin.dashboard.range.${statsRange}`);
|
const formatRangeDate = (iso: string) => {
|
||||||
const prevRangeLabel = t(`admin.dashboard.prevRange.${statsRange}`);
|
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) => {
|
const dayLabel = (iso: string) => {
|
||||||
try {
|
try {
|
||||||
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
||||||
@@ -974,12 +1025,18 @@ function DashboardSection({
|
|||||||
</div>
|
</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: "7d", label: t("admin.dashboard.range.7d") },
|
||||||
{ value: "30d", label: t("admin.dashboard.range.30d") },
|
{ value: "30d", label: t("admin.dashboard.range.30d") },
|
||||||
{ value: "90d", label: t("admin.dashboard.range.90d") },
|
{ 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
@@ -996,31 +1053,74 @@ function DashboardSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-2xl p-3 flex items-center justify-between gap-3 flex-wrap">
|
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||||
<div className="text-sm font-semibold text-foreground">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
{t("admin.dashboard.trends")}
|
<div className="text-sm font-semibold text-foreground">
|
||||||
</div>
|
{t("admin.dashboard.trends")}
|
||||||
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
|
</div>
|
||||||
{rangeOptions.map((opt) => {
|
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
|
||||||
const active = statsRange === opt.value;
|
{rangeOptions.map((opt) => {
|
||||||
return (
|
const active = statsRange === opt.value;
|
||||||
<button
|
return (
|
||||||
key={opt.value}
|
<button
|
||||||
type="button"
|
key={opt.value}
|
||||||
onClick={() => onStatsRangeChange(opt.value)}
|
type="button"
|
||||||
aria-pressed={active}
|
onClick={() => onStatsRangeChange(opt.value)}
|
||||||
className={cn(
|
aria-pressed={active}
|
||||||
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
|
className={cn(
|
||||||
active
|
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
|
||||||
? "bg-white text-foreground shadow-sm"
|
active
|
||||||
: "text-muted-foreground hover:text-foreground",
|
? "bg-white text-foreground shadow-sm"
|
||||||
)}
|
: "text-muted-foreground hover:text-foreground",
|
||||||
>
|
)}
|
||||||
{opt.label}
|
>
|
||||||
</button>
|
{opt.label}
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ export const AdminStatsRange = {
|
|||||||
"7d": "7d",
|
"7d": "7d",
|
||||||
"30d": "30d",
|
"30d": "30d",
|
||||||
"90d": "90d",
|
"90d": "90d",
|
||||||
|
custom: "custom",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AdminStatsSignupsByDayItem = {
|
export type AdminStatsSignupsByDayItem = {
|
||||||
@@ -415,6 +416,10 @@ export interface TopUserItem {
|
|||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
range: AdminStatsRange;
|
range: AdminStatsRange;
|
||||||
rangeDays: number;
|
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;
|
newUsersInRange: number;
|
||||||
newUsersPrevRange: number;
|
newUsersPrevRange: number;
|
||||||
activeServices: number;
|
activeServices: number;
|
||||||
@@ -431,9 +436,17 @@ export interface AdminStats {
|
|||||||
|
|
||||||
export type GetAdminStatsParams = {
|
export type GetAdminStatsParams = {
|
||||||
/**
|
/**
|
||||||
* Time range for trend stats
|
* Time range for trend stats. Use "custom" with from/to.
|
||||||
*/
|
*/
|
||||||
range?: GetAdminStatsRange;
|
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 =
|
export type GetAdminStatsRange =
|
||||||
@@ -443,4 +456,5 @@ export const GetAdminStatsRange = {
|
|||||||
"7d": "7d",
|
"7d": "7d",
|
||||||
"30d": "30d",
|
"30d": "30d",
|
||||||
"90d": "90d",
|
"90d": "90d",
|
||||||
|
custom: "custom",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -861,9 +861,23 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: ["7d", "30d", "90d"]
|
enum: ["7d", "30d", "90d", "custom"]
|
||||||
default: "7d"
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Admin stats
|
description: Admin stats
|
||||||
@@ -1554,9 +1568,15 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
range:
|
range:
|
||||||
type: string
|
type: string
|
||||||
enum: ["7d", "30d", "90d"]
|
enum: ["7d", "30d", "90d", "custom"]
|
||||||
rangeDays:
|
rangeDays:
|
||||||
type: integer
|
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:
|
newUsersInRange:
|
||||||
type: integer
|
type: integer
|
||||||
newUsersPrevRange:
|
newUsersPrevRange:
|
||||||
|
|||||||
@@ -852,14 +852,30 @@ export const getAdminStatsQueryRangeDefault = `7d`;
|
|||||||
|
|
||||||
export const GetAdminStatsQueryParams = zod.object({
|
export const GetAdminStatsQueryParams = zod.object({
|
||||||
range: zod
|
range: zod
|
||||||
.enum(["7d", "30d", "90d"])
|
.enum(["7d", "30d", "90d", "custom"])
|
||||||
.default(getAdminStatsQueryRangeDefault)
|
.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({
|
export const GetAdminStatsResponse = zod.object({
|
||||||
range: zod.enum(["7d", "30d", "90d"]),
|
range: zod.enum(["7d", "30d", "90d", "custom"]),
|
||||||
rangeDays: zod.number(),
|
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(),
|
newUsersInRange: zod.number(),
|
||||||
newUsersPrevRange: zod.number(),
|
newUsersPrevRange: zod.number(),
|
||||||
activeServices: zod.number(),
|
activeServices: zod.number(),
|
||||||
|
|||||||
Reference in New Issue
Block a user