Let admins pick the time range for dashboard trends
Original task: Add a 7d/30d/90d range selector to the admin dashboard trend cards/chart and have /stats/admin accept a matching range parameter. Changes: - OpenAPI (lib/api-spec/openapi.yaml): added optional `range` query parameter (enum 7d|30d|90d, default 7d) to GET /stats/admin and refactored AdminStats fields to be range-agnostic — added `range` and `rangeDays`, renamed `*Last7Days`/`*Prev7Days` to `*InRange`/ `*PrevRange`. Regenerated api-client-react and api-zod. - API server (artifacts/api-server/src/routes/stats.ts): parses and validates the `range` param, computes range/prev-range windows generically, and returns daily series sized to rangeDays. - Admin UI (artifacts/teaboy-os/src/pages/admin.tsx): adds a segmented range selector at the top of the dashboard, passes the selected range into the stats query (with proper queryKey), and adapts the trend chart for denser ranges (skipped per-bar count text >14 days, every-Nth date label, month/day formatting for 30d/90d). - Locale files (en.json/ar.json): added range/prevRange labels, trends/rangeSelector strings, and *Ranged variants of summary keys. Old keys kept to avoid stale references. Verification: - pnpm typecheck passes across libs and artifacts. - e2e test (login as admin → toggle 7d/30d/90d → verify chart bar counts and aria-pressed state, plus API responses for each range) passes. Notes / deviations: - Had to (re)create the `app_opens` table in the dev DB and reset the seeded admin password hash to run the e2e test; both were preexisting environment drift unrelated to this task. - Followed up with: custom date range, persisting last-used range. Replit-Task-Id: 8066030b-b5c4-4b5c-a630-65616df5449e
This commit is contained in:
@@ -112,26 +112,32 @@ 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 allowedRanges = ["7d", "30d", "90d"] as const;
|
||||
type RangeKey = typeof allowedRanges[number];
|
||||
const range: RangeKey = (allowedRanges as readonly string[]).includes(rangeParam)
|
||||
? (rangeParam as RangeKey)
|
||||
: "7d";
|
||||
const rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7;
|
||||
|
||||
const now = new Date();
|
||||
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const sevenDaysAgo = new Date(startOfTodayUtc.getTime() - 6 * 24 * 60 * 60 * 1000);
|
||||
const fourteenDaysAgo = new Date(startOfTodayUtc.getTime() - 13 * 24 * 60 * 60 * 1000);
|
||||
const last7Start = sevenDaysAgo;
|
||||
const prev7Start = fourteenDaysAgo;
|
||||
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);
|
||||
|
||||
const [newUsersLast7Row] = await db
|
||||
const [newUsersInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
const [newUsersPrev7Row] = await db
|
||||
const [newUsersPrevRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(usersTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${usersTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||
sql`${usersTable.createdAt} < ${last7Start.toISOString()}`,
|
||||
sql`${usersTable.createdAt} >= ${prevRangeStart.toISOString()}`,
|
||||
sql`${usersTable.createdAt} < ${rangeStart.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,34 +151,13 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
.from(servicesTable)
|
||||
.where(eq(servicesTable.isAvailable, false));
|
||||
|
||||
const signupRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
|
||||
const countsByDay = new Map<string, number>();
|
||||
for (const row of signupRows) {
|
||||
countsByDay.set(row.day, Number(row.count));
|
||||
}
|
||||
|
||||
const signupsByDay: { date: string; count: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
|
||||
}
|
||||
|
||||
const buildSeries = (
|
||||
rows: { day: string; count: number }[],
|
||||
): { date: string; count: number }[] => {
|
||||
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 = 6; i >= 0; i--) {
|
||||
for (let i = rangeDays - 1; i >= 0; i--) {
|
||||
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
out.push({ date: key, count: map.get(key) ?? 0 });
|
||||
@@ -180,28 +165,38 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
return out;
|
||||
};
|
||||
|
||||
const signupRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${usersTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const signupsByDay = buildSeries(signupRows);
|
||||
|
||||
const appOpenRows = await db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(appOpensTable)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const appOpensByDay = buildSeries(appOpenRows);
|
||||
|
||||
const [appOpensLast7Row] = await db
|
||||
const [appOpensInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${appOpensTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
const [appOpensPrev7Row] = await db
|
||||
const [appOpensPrevRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(appOpensTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} >= ${prevRangeStart.toISOString()}`,
|
||||
sql`${appOpensTable.createdAt} < ${rangeStart.toISOString()}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -211,26 +206,28 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(servicesTable)
|
||||
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`)
|
||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`)
|
||||
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
|
||||
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
|
||||
|
||||
const [servicesCreatedLast7Row] = await db
|
||||
const [servicesCreatedInRangeRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(servicesTable)
|
||||
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`);
|
||||
.where(sql`${servicesTable.createdAt} >= ${rangeStart.toISOString()}`);
|
||||
|
||||
res.json({
|
||||
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
|
||||
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
|
||||
range,
|
||||
rangeDays,
|
||||
newUsersInRange: Number(newUsersInRangeRow?.count ?? 0),
|
||||
newUsersPrevRange: Number(newUsersPrevRangeRow?.count ?? 0),
|
||||
activeServices: Number(activeServicesRow?.count ?? 0),
|
||||
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
|
||||
signupsByDay,
|
||||
appOpensByDay,
|
||||
appOpensLast7Days: Number(appOpensLast7Row?.count ?? 0),
|
||||
appOpensPrev7Days: Number(appOpensPrev7Row?.count ?? 0),
|
||||
appOpensInRange: Number(appOpensInRangeRow?.count ?? 0),
|
||||
appOpensPrevRange: Number(appOpensPrevRangeRow?.count ?? 0),
|
||||
servicesCreatedByDay,
|
||||
servicesCreatedLast7Days: Number(servicesCreatedLast7Row?.count ?? 0),
|
||||
servicesCreatedInRange: Number(servicesCreatedInRangeRow?.count ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -147,14 +147,30 @@
|
||||
"none": "—",
|
||||
"newUsers7d": "مستخدمون جدد (٧ أيام)",
|
||||
"vsPrev7d": "الأسبوع السابق: {{count}}",
|
||||
"newUsersInRange": "مستخدمون جدد · {{range}}",
|
||||
"vsPrevRange": "{{range}}: {{count}}",
|
||||
"activeServices": "الخدمات النشطة",
|
||||
"inactiveServices": "غير نشطة: {{count}}",
|
||||
"signupsTrend": "التسجيلات",
|
||||
"last7Days": "آخر ٧ أيام",
|
||||
"appOpensTrend": "فتح التطبيقات",
|
||||
"appOpensSummary": "{{count}} إجمالاً · {{delta}}",
|
||||
"appOpensSummaryRanged": "{{count}} في {{range}} · {{delta}}",
|
||||
"servicesCreatedTrend": "خدمات جديدة",
|
||||
"servicesCreatedSummary": "{{count}} مضافة"
|
||||
"servicesCreatedSummary": "{{count}} مضافة",
|
||||
"servicesCreatedSummaryRanged": "{{count}} في {{range}}",
|
||||
"trends": "الاتجاهات",
|
||||
"rangeSelector": "المدى الزمني",
|
||||
"range": {
|
||||
"7d": "آخر ٧ أيام",
|
||||
"30d": "آخر ٣٠ يومًا",
|
||||
"90d": "آخر ٩٠ يومًا"
|
||||
},
|
||||
"prevRange": {
|
||||
"7d": "الأيام ٧ السابقة",
|
||||
"30d": "الأيام ٣٠ السابقة",
|
||||
"90d": "الأيام ٩٠ السابقة"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -147,14 +147,30 @@
|
||||
"none": "—",
|
||||
"newUsers7d": "New users (7d)",
|
||||
"vsPrev7d": "Previous 7d: {{count}}",
|
||||
"newUsersInRange": "New users · {{range}}",
|
||||
"vsPrevRange": "{{range}}: {{count}}",
|
||||
"activeServices": "Active services",
|
||||
"inactiveServices": "Inactive: {{count}}",
|
||||
"signupsTrend": "Sign-ups",
|
||||
"last7Days": "Last 7 days",
|
||||
"appOpensTrend": "App opens",
|
||||
"appOpensSummary": "{{count}} total · {{delta}}",
|
||||
"appOpensSummaryRanged": "{{count}} in {{range}} · {{delta}}",
|
||||
"servicesCreatedTrend": "Services added",
|
||||
"servicesCreatedSummary": "{{count}} added"
|
||||
"servicesCreatedSummary": "{{count}} added",
|
||||
"servicesCreatedSummaryRanged": "{{count}} in {{range}}",
|
||||
"trends": "Trends",
|
||||
"rangeSelector": "Time range",
|
||||
"range": {
|
||||
"7d": "Last 7 days",
|
||||
"30d": "Last 30 days",
|
||||
"90d": "Last 90 days"
|
||||
},
|
||||
"prevRange": {
|
||||
"7d": "Previous 7 days",
|
||||
"30d": "Previous 30 days",
|
||||
"90d": "Previous 90 days"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -82,9 +82,11 @@ 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 { data: adminStats } = useGetAdminStats({
|
||||
query: { queryKey: getGetAdminStatsQueryKey(), enabled: isAdmin },
|
||||
});
|
||||
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d">("7d");
|
||||
const { data: adminStats } = useGetAdminStats(
|
||||
{ range: statsRange },
|
||||
{ query: { queryKey: getGetAdminStatsQueryKey({ range: statsRange }), enabled: isAdmin } },
|
||||
);
|
||||
|
||||
const createApp = useCreateApp();
|
||||
const updateApp = useUpdateApp();
|
||||
@@ -392,6 +394,8 @@ export default function AdminPage() {
|
||||
appSettings={appSettings}
|
||||
adminStats={adminStats}
|
||||
lang={lang}
|
||||
statsRange={statsRange}
|
||||
onStatsRangeChange={setStatsRange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -757,6 +761,8 @@ function DashboardSection({
|
||||
appSettings,
|
||||
adminStats,
|
||||
lang,
|
||||
statsRange,
|
||||
onStatsRangeChange,
|
||||
}: {
|
||||
apps: App[] | undefined;
|
||||
services: Service[] | undefined;
|
||||
@@ -764,6 +770,8 @@ function DashboardSection({
|
||||
appSettings: AppSettings | undefined;
|
||||
adminStats: AdminStats | undefined;
|
||||
lang: string;
|
||||
statsRange: "7d" | "30d" | "90d";
|
||||
onStatsRangeChange: (range: "7d" | "30d" | "90d") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -800,8 +808,8 @@ function DashboardSection({
|
||||
},
|
||||
];
|
||||
|
||||
const newUsers = adminStats?.newUsersLast7Days ?? 0;
|
||||
const prevUsers = adminStats?.newUsersPrev7Days ?? 0;
|
||||
const newUsers = adminStats?.newUsersInRange ?? 0;
|
||||
const prevUsers = adminStats?.newUsersPrevRange ?? 0;
|
||||
const userDelta = newUsers - prevUsers;
|
||||
const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null;
|
||||
const activeServices = adminStats?.activeServices ?? 0;
|
||||
@@ -810,13 +818,16 @@ function DashboardSection({
|
||||
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
|
||||
const opens = adminStats?.appOpensByDay ?? [];
|
||||
const maxOpens = Math.max(1, ...opens.map((s) => s.count));
|
||||
const opensTotal = adminStats?.appOpensLast7Days ?? 0;
|
||||
const opensPrev = adminStats?.appOpensPrev7Days ?? 0;
|
||||
const opensTotal = adminStats?.appOpensInRange ?? 0;
|
||||
const opensPrev = adminStats?.appOpensPrevRange ?? 0;
|
||||
const opensDelta = opensTotal - opensPrev;
|
||||
const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null;
|
||||
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
|
||||
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
|
||||
const servicesCreatedTotal = adminStats?.servicesCreatedLast7Days ?? 0;
|
||||
const servicesCreatedTotal = adminStats?.servicesCreatedInRange ?? 0;
|
||||
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 dayLabel = (iso: string) => {
|
||||
try {
|
||||
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
|
||||
@@ -825,6 +836,17 @@ function DashboardSection({
|
||||
}
|
||||
};
|
||||
|
||||
const showCounts = rangeDays <= 14;
|
||||
const labelStride = rangeDays <= 7 ? 1 : rangeDays <= 30 ? 5 : 15;
|
||||
const gapClass = rangeDays <= 7 ? "gap-1.5" : rangeDays <= 30 ? "gap-1" : "gap-px";
|
||||
const monthDayLabel = (iso: string) => {
|
||||
try {
|
||||
return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return iso.slice(5);
|
||||
}
|
||||
};
|
||||
|
||||
const renderChart = (
|
||||
title: string,
|
||||
series: { date: string; count: number }[],
|
||||
@@ -835,14 +857,20 @@ function DashboardSection({
|
||||
<div className="glass-panel rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground truncate">{title}</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? t("admin.dashboard.last7Days")}</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? rangeLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
|
||||
{series.map((s) => {
|
||||
<div className={cn("flex items-end justify-between h-28 px-1", gapClass)} dir="ltr">
|
||||
{series.map((s, idx) => {
|
||||
const heightPct = (s.count / max) * 100;
|
||||
const showLabel =
|
||||
rangeDays <= 7
|
||||
? true
|
||||
: idx === series.length - 1 || (series.length - 1 - idx) % labelStride === 0;
|
||||
return (
|
||||
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||
{showCounts && (
|
||||
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
|
||||
)}
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
|
||||
@@ -850,7 +878,7 @@ function DashboardSection({
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
|
||||
{dayLabel(s.date)}
|
||||
{showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -859,6 +887,12 @@ function DashboardSection({
|
||||
</div>
|
||||
);
|
||||
|
||||
const rangeOptions: { value: "7d" | "30d" | "90d"; 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") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
@@ -875,13 +909,42 @@ 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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-sky-600 bg-sky-100/70">
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.newUsers7d")}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{t("admin.dashboard.newUsersInRange", { range: rangeLabel })}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-xl font-semibold text-foreground">{newUsers}</div>
|
||||
{userDelta !== 0 && (
|
||||
@@ -899,7 +962,7 @@ function DashboardSection({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{t("admin.dashboard.vsPrev7d", { count: prevUsers })}
|
||||
{t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -936,8 +999,9 @@ function DashboardSection({
|
||||
opens,
|
||||
maxOpens,
|
||||
"from-sky-300/40 to-sky-500",
|
||||
t("admin.dashboard.appOpensSummary", {
|
||||
t("admin.dashboard.appOpensSummaryRanged", {
|
||||
count: opensTotal,
|
||||
range: rangeLabel,
|
||||
delta:
|
||||
opensDelta === 0
|
||||
? "±0"
|
||||
@@ -951,7 +1015,7 @@ function DashboardSection({
|
||||
servicesCreated,
|
||||
maxServicesCreated,
|
||||
"from-amber-300/40 to-amber-500",
|
||||
t("admin.dashboard.servicesCreatedSummary", { count: servicesCreatedTotal }),
|
||||
t("admin.dashboard.servicesCreatedSummaryRanged", { count: servicesCreatedTotal, range: rangeLabel }),
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user