diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index 8b7feaf1..68fcff6e 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -114,22 +114,67 @@ router.get("/stats/home", requireAuth, async (req, res): Promise => { router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise => { 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`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`count(*)` }) @@ -157,8 +202,8 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise< const map = new Map(); 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`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`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`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`count(*)` }) @@ -206,14 +266,24 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise< count: sql`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`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), diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 5458050d..0fd74ef7 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -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}}", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 600461cd..55a488cc 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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}}", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 297c8061..e6fa049e 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -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(sevenAgoIso); + const [customTo, setCustomTo] = useState(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({ ); - 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 (
@@ -996,31 +1053,74 @@ function DashboardSection({ ))}
-
-
- {t("admin.dashboard.trends")} -
-
- {rangeOptions.map((opt) => { - const active = statsRange === opt.value; - return ( - - ); - })} +
+
+
+ {t("admin.dashboard.trends")} +
+
+ {rangeOptions.map((opt) => { + const active = statsRange === opt.value; + return ( + + ); + })} +
+ {statsRange === "custom" && ( +
+
+ + onCustomFromChange(e.target.value)} + className="bg-white/70 border-slate-200 h-9 text-sm" + /> +
+
+ + onCustomToChange(e.target.value)} + className="bg-white/70 border-slate-200 h-9 text-sm" + /> +
+ + {!customInputsValid && ( +
+ {t("admin.dashboard.customRange.invalid")} +
+ )} +
+ )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 575b08f2..219d986d 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 278ddd06..5b2e57eb 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8f284dab..01838637 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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(),