Task #48: Let admins page through more than the last 100 opens
Added offset/limit pagination to the two admin app-opens drill-in endpoints so admins can investigate spikes that span more than the default 100 most-recent opens. Backend (artifacts/api-server/src/routes/stats.ts): - New parsePaging() helper validates `limit` (1..200, default 100) and `offset` (>=0, default 0); invalid values return 400. - Both `/stats/admin/app-opens/by-app/:appId` and `/stats/admin/app-opens/by-user/:userId` accept the new params, apply `.limit(limit).offset(offset)`, and return `limit`, `offset`, and a `nextOffset` (number | null) computed from `totalCount`. - Added a stable secondary sort (`id desc`) so paged results don't shuffle when timestamps tie. Spec & client (lib/api-spec/openapi.yaml + regenerated clients): - Added `limit`/`offset` query params and `limit`/`offset`/`nextOffset` response fields to AdminAppOpensByApp/AdminAppOpensByUser. - Re-ran `pnpm --filter @workspace/api-spec run codegen`. Frontend (artifacts/teaboy-os/src/pages/admin.tsx + locales): - AppOpensDrillIn / UserOpensDrillIn now accumulate extra pages in local state and expose a "Load more" button via a shared LoadMoreSection footer that also shows "Showing X of Y". - Extra pages are fetched via the generated `getAdminAppOpensByApp` / `getAdminAppOpensByUser` functions; accumulated state resets when the appId/userId or stats query params change. - Added en/ar translations for `loadMore`, `loadMoreError`, `shownOf`. Tests: - New artifacts/api-server/tests/admin-app-opens-pagination.test.mjs covers happy-path paging for both endpoints, the default page size, and 400 responses for invalid limit/offset. - All 10 api-server tests pass; full workspace typecheck passes. Replit-Task-Id: b6382efe-765f-4689-8c93-196fee253f63
This commit is contained in:
@@ -427,7 +427,33 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise<
|
||||
});
|
||||
});
|
||||
|
||||
const RECENT_OPENS_LIMIT = 100;
|
||||
const RECENT_OPENS_DEFAULT_LIMIT = 100;
|
||||
const RECENT_OPENS_MAX_LIMIT = 200;
|
||||
|
||||
function parsePaging(query: { limit?: unknown; offset?: unknown }):
|
||||
| { limit: number; offset: number }
|
||||
| { error: string } {
|
||||
let limit = RECENT_OPENS_DEFAULT_LIMIT;
|
||||
let offset = 0;
|
||||
if (query.limit !== undefined) {
|
||||
const n = Number(query.limit);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
return { error: "Invalid limit: must be a positive integer" };
|
||||
}
|
||||
if (n > RECENT_OPENS_MAX_LIMIT) {
|
||||
return { error: `Invalid limit: maximum is ${RECENT_OPENS_MAX_LIMIT}` };
|
||||
}
|
||||
limit = n;
|
||||
}
|
||||
if (query.offset !== undefined) {
|
||||
const n = Number(query.offset);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
return { error: "Invalid offset: must be a non-negative integer" };
|
||||
}
|
||||
offset = n;
|
||||
}
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/stats/admin/app-opens/by-app/:appId",
|
||||
@@ -447,6 +473,13 @@ router.get(
|
||||
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
|
||||
parsed.window;
|
||||
|
||||
const paging = parsePaging(req.query);
|
||||
if ("error" in paging) {
|
||||
res.status(400).json({ error: paging.error });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = paging;
|
||||
|
||||
const [app] = await db
|
||||
.select({
|
||||
id: appsTable.id,
|
||||
@@ -493,8 +526,12 @@ router.get(
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`)
|
||||
.limit(RECENT_OPENS_LIMIT);
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`, sql`${appOpensTable.id} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalCount = Number(totalRow?.count ?? 0);
|
||||
const nextOffset = offset + opensRows.length < totalCount ? offset + opensRows.length : null;
|
||||
|
||||
res.json({
|
||||
appId: app.id,
|
||||
@@ -507,7 +544,10 @@ router.get(
|
||||
rangeDays,
|
||||
rangeFrom: rangeFromStr,
|
||||
rangeTo: rangeToStr,
|
||||
totalCount: Number(totalRow?.count ?? 0),
|
||||
totalCount,
|
||||
limit,
|
||||
offset,
|
||||
nextOffset,
|
||||
opens: opensRows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt:
|
||||
@@ -540,6 +580,13 @@ router.get(
|
||||
const { range, rangeStart, rangeEndExclusive, rangeDays, rangeFromStr, rangeToStr } =
|
||||
parsed.window;
|
||||
|
||||
const paging = parsePaging(req.query);
|
||||
if ("error" in paging) {
|
||||
res.status(400).json({ error: paging.error });
|
||||
return;
|
||||
}
|
||||
const { limit, offset } = paging;
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
@@ -586,8 +633,12 @@ router.get(
|
||||
sql`${appOpensTable.createdAt} < ${rangeEndExclusive.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`)
|
||||
.limit(RECENT_OPENS_LIMIT);
|
||||
.orderBy(sql`${appOpensTable.createdAt} desc`, sql`${appOpensTable.id} desc`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalCount = Number(totalRow?.count ?? 0);
|
||||
const nextOffset = offset + opensRows.length < totalCount ? offset + opensRows.length : null;
|
||||
|
||||
res.json({
|
||||
userId: user.id,
|
||||
@@ -599,7 +650,10 @@ router.get(
|
||||
rangeDays,
|
||||
rangeFrom: rangeFromStr,
|
||||
rangeTo: rangeToStr,
|
||||
totalCount: Number(totalRow?.count ?? 0),
|
||||
totalCount,
|
||||
limit,
|
||||
offset,
|
||||
nextOffset,
|
||||
opens: opensRows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
let adminUserId;
|
||||
let adminUsername;
|
||||
let appId;
|
||||
let sessionCookie;
|
||||
const insertedOpenIds = [];
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
assert.ok(setCookie, "expected Set-Cookie header from login");
|
||||
const sid = setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
assert.ok(sid, "expected connect.sid cookie");
|
||||
return sid;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminUsername = `admin_paging_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
|
||||
const userRes = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Admin Paging Test', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminUserId = userRes.rows[0].id;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminUserId],
|
||||
);
|
||||
|
||||
const appRes = await pool.query(
|
||||
`SELECT id FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`,
|
||||
);
|
||||
assert.ok(appRes.rows[0], "no active app found to test against");
|
||||
appId = appRes.rows[0].id;
|
||||
|
||||
// Insert 3 opens for this admin user/app within the past few minutes,
|
||||
// strictly within the 7d range.
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ts = new Date(now - i * 60 * 1000).toISOString();
|
||||
const r = await pool.query(
|
||||
`INSERT INTO app_opens (user_id, app_id, created_at) VALUES ($1, $2, $3) RETURNING id`,
|
||||
[adminUserId, appId, ts],
|
||||
);
|
||||
insertedOpenIds.push(r.rows[0].id);
|
||||
}
|
||||
|
||||
sessionCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (insertedOpenIds.length > 0) {
|
||||
await pool.query(`DELETE FROM app_opens WHERE id = ANY($1::int[])`, [insertedOpenIds]);
|
||||
}
|
||||
if (adminUserId !== undefined) {
|
||||
await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [adminUserId]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminUserId]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [adminUserId]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function getJson(path) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Cookie: sessionCookie },
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
test("by-app: paginating with limit returns nextOffset until exhausted", async () => {
|
||||
const page1 = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2`,
|
||||
);
|
||||
assert.equal(page1.status, 200);
|
||||
assert.ok(page1.body.totalCount >= 3, "totalCount should include our inserted opens");
|
||||
assert.equal(page1.body.limit, 2);
|
||||
assert.equal(page1.body.offset, 0);
|
||||
assert.equal(page1.body.opens.length, 2);
|
||||
assert.equal(page1.body.nextOffset, 2);
|
||||
|
||||
const page2 = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=2`,
|
||||
);
|
||||
assert.equal(page2.status, 200);
|
||||
assert.equal(page2.body.offset, 2);
|
||||
assert.equal(page2.body.opens.length >= 1, true);
|
||||
|
||||
// Walk to the last page; nextOffset eventually becomes null.
|
||||
let nextOffset = page2.body.nextOffset;
|
||||
let safety = 50;
|
||||
while (nextOffset !== null && safety-- > 0) {
|
||||
const np = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=${nextOffset}`,
|
||||
);
|
||||
assert.equal(np.status, 200);
|
||||
nextOffset = np.body.nextOffset;
|
||||
}
|
||||
assert.equal(nextOffset, null, "should reach end of pages");
|
||||
});
|
||||
|
||||
test("by-user: paginating with limit returns nextOffset until exhausted", async () => {
|
||||
const page1 = await getJson(
|
||||
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2`,
|
||||
);
|
||||
assert.equal(page1.status, 200);
|
||||
assert.equal(page1.body.totalCount, 3);
|
||||
assert.equal(page1.body.limit, 2);
|
||||
assert.equal(page1.body.offset, 0);
|
||||
assert.equal(page1.body.opens.length, 2);
|
||||
assert.equal(page1.body.nextOffset, 2);
|
||||
|
||||
const page2 = await getJson(
|
||||
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2&offset=2`,
|
||||
);
|
||||
assert.equal(page2.status, 200);
|
||||
assert.equal(page2.body.offset, 2);
|
||||
assert.equal(page2.body.opens.length, 1);
|
||||
assert.equal(page2.body.nextOffset, null);
|
||||
});
|
||||
|
||||
test("by-app: invalid limit returns 400", async () => {
|
||||
const r = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=0`,
|
||||
);
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
test("by-app: limit above max returns 400", async () => {
|
||||
const r = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=999`,
|
||||
);
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
test("by-user: invalid offset returns 400", async () => {
|
||||
const r = await getJson(
|
||||
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&offset=-1`,
|
||||
);
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
test("by-app: default page size is 100 when no limit specified", async () => {
|
||||
const r = await getJson(
|
||||
`/api/stats/admin/app-opens/by-app/${appId}?range=7d`,
|
||||
);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.limit, 100);
|
||||
assert.equal(r.body.offset, 0);
|
||||
});
|
||||
@@ -331,7 +331,10 @@
|
||||
"empty": "لا توجد فتحات في هذا النطاق",
|
||||
"loadError": "تعذّر تحميل آخر الفتحات.",
|
||||
"editApp": "تعديل التطبيق",
|
||||
"viewUser": "عرض المستخدم"
|
||||
"viewUser": "عرض المستخدم",
|
||||
"loadMore": "تحميل المزيد",
|
||||
"loadMoreError": "تعذّر تحميل المزيد من الفتحات.",
|
||||
"shownOf": "عرض {{shown}} من {{total}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -328,7 +328,10 @@
|
||||
"empty": "No opens in this range",
|
||||
"loadError": "Couldn't load recent opens.",
|
||||
"editApp": "Edit app",
|
||||
"viewUser": "View user"
|
||||
"viewUser": "View user",
|
||||
"loadMore": "Load more",
|
||||
"loadMoreError": "Couldn't load more opens.",
|
||||
"shownOf": "Showing {{shown}} of {{total}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
getGetAdminStatsQueryKey,
|
||||
useGetAdminAppOpensByApp,
|
||||
getGetAdminAppOpensByAppQueryKey,
|
||||
getAdminAppOpensByApp,
|
||||
useGetAdminAppOpensByUser,
|
||||
getGetAdminAppOpensByUserQueryKey,
|
||||
getAdminAppOpensByUser,
|
||||
useAdminIssueResetLink,
|
||||
} from "@workspace/api-client-react";
|
||||
import type {
|
||||
@@ -34,6 +36,8 @@ import type {
|
||||
UserProfile,
|
||||
AppSettings,
|
||||
AdminStats,
|
||||
AdminAppOpensByApp,
|
||||
AdminAppOpensByUser,
|
||||
GetAdminStatsQueryError,
|
||||
GetAdminStatsParams,
|
||||
} from "@workspace/api-client-react";
|
||||
@@ -1632,6 +1636,22 @@ function AppOpensDrillIn({
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
type ExtraOpen = AdminAppOpensByApp["opens"][number];
|
||||
const [extraOpens, setExtraOpens] = useState<ExtraOpen[]>([]);
|
||||
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
|
||||
const [hasExtras, setHasExtras] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||
|
||||
const paramsKey = JSON.stringify(statsQueryParams);
|
||||
useEffect(() => {
|
||||
setExtraOpens([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [appId, paramsKey]);
|
||||
|
||||
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
|
||||
const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : "";
|
||||
const title = appName
|
||||
@@ -1640,6 +1660,27 @@ function AppOpensDrillIn({
|
||||
const subtitle = data
|
||||
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
|
||||
: rangeLabel;
|
||||
const opens = data ? [...data.opens, ...extraOpens] : [];
|
||||
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
|
||||
|
||||
const onLoadMore = async () => {
|
||||
if (nextOffset === null || isLoadingMore) return;
|
||||
setIsLoadingMore(true);
|
||||
setLoadMoreError(null);
|
||||
try {
|
||||
const next = await getAdminAppOpensByApp(appId, {
|
||||
...statsQueryParams,
|
||||
offset: nextOffset,
|
||||
});
|
||||
setExtraOpens((prev) => [...prev, ...next.opens]);
|
||||
setExtraNextOffset(next.nextOffset);
|
||||
setHasExtras(true);
|
||||
} catch (e) {
|
||||
setLoadMoreError((e as { message?: string }).message ?? "Error");
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DrillInShell
|
||||
@@ -1663,45 +1704,97 @@ function AppOpensDrillIn({
|
||||
{t("admin.dashboard.drillIn.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
|
||||
</div>
|
||||
) : !data || data.opens.length === 0 ? (
|
||||
) : !data || opens.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-8 text-center">
|
||||
{t("admin.dashboard.drillIn.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{data.opens.map((o) => {
|
||||
const display =
|
||||
(lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
|
||||
const initial = (display || o.username || "?").trim().charAt(0).toUpperCase();
|
||||
const avatar = resolveServiceImageUrl(o.avatarUrl);
|
||||
return (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpUser(o.userId)}
|
||||
aria-label={t("admin.dashboard.viewUserDetails", { name: display })}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
|
||||
>
|
||||
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{display}</div>
|
||||
{display !== o.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<>
|
||||
<ol className="space-y-1.5">
|
||||
{opens.map((o) => {
|
||||
const display =
|
||||
(lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
|
||||
const initial = (display || o.username || "?").trim().charAt(0).toUpperCase();
|
||||
const avatar = resolveServiceImageUrl(o.avatarUrl);
|
||||
return (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpUser(o.userId)}
|
||||
aria-label={t("admin.dashboard.viewUserDetails", { name: display })}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
|
||||
>
|
||||
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{display}</div>
|
||||
{display !== o.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<LoadMoreSection
|
||||
shownCount={opens.length}
|
||||
totalCount={data.totalCount}
|
||||
nextOffset={nextOffset}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMoreError={loadMoreError}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DrillInShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadMoreSection({
|
||||
shownCount,
|
||||
totalCount,
|
||||
nextOffset,
|
||||
isLoadingMore,
|
||||
loadMoreError,
|
||||
onLoadMore,
|
||||
}: {
|
||||
shownCount: number;
|
||||
totalCount: number;
|
||||
nextOffset: number | null;
|
||||
isLoadingMore: boolean;
|
||||
loadMoreError: string | null;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-3 flex flex-col items-center gap-1.5">
|
||||
<div className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t("admin.dashboard.drillIn.shownOf", { shown: shownCount, total: totalCount })}
|
||||
</div>
|
||||
{nextOffset !== null && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="gap-2"
|
||||
>
|
||||
{isLoadingMore && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("admin.dashboard.drillIn.loadMore")}
|
||||
</Button>
|
||||
)}
|
||||
{loadMoreError && (
|
||||
<div className="text-[11px] text-destructive">
|
||||
{t("admin.dashboard.drillIn.loadMoreError")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserOpensDrillIn({
|
||||
userId,
|
||||
lang,
|
||||
@@ -1726,10 +1819,47 @@ function UserOpensDrillIn({
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
type ExtraOpen = AdminAppOpensByUser["opens"][number];
|
||||
const [extraOpens, setExtraOpens] = useState<ExtraOpen[]>([]);
|
||||
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
|
||||
const [hasExtras, setHasExtras] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||
|
||||
const paramsKey = JSON.stringify(statsQueryParams);
|
||||
useEffect(() => {
|
||||
setExtraOpens([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [userId, paramsKey]);
|
||||
|
||||
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
|
||||
const displayName = data
|
||||
? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username
|
||||
: "";
|
||||
const opens = data ? [...data.opens, ...extraOpens] : [];
|
||||
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
|
||||
|
||||
const onLoadMore = async () => {
|
||||
if (nextOffset === null || isLoadingMore) return;
|
||||
setIsLoadingMore(true);
|
||||
setLoadMoreError(null);
|
||||
try {
|
||||
const next = await getAdminAppOpensByUser(userId, {
|
||||
...statsQueryParams,
|
||||
offset: nextOffset,
|
||||
});
|
||||
setExtraOpens((prev) => [...prev, ...next.opens]);
|
||||
setExtraNextOffset(next.nextOffset);
|
||||
setHasExtras(true);
|
||||
} catch (e) {
|
||||
setLoadMoreError((e as { message?: string }).message ?? "Error");
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
const title = displayName
|
||||
? t("admin.dashboard.drillIn.userTitle", { name: displayName })
|
||||
: t("admin.dashboard.drillIn.loading");
|
||||
@@ -1759,37 +1889,47 @@ function UserOpensDrillIn({
|
||||
{t("admin.dashboard.drillIn.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
|
||||
</div>
|
||||
) : !data || data.opens.length === 0 ? (
|
||||
) : !data || opens.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-8 text-center">
|
||||
{t("admin.dashboard.drillIn.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{data.opens.map((o) => {
|
||||
const name = lang === "ar" ? o.nameAr : o.nameEn;
|
||||
return (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpApp(o.appId)}
|
||||
aria-label={t("admin.dashboard.viewAppDetails", { name })}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${o.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{name}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<>
|
||||
<ol className="space-y-1.5">
|
||||
{opens.map((o) => {
|
||||
const name = lang === "ar" ? o.nameAr : o.nameEn;
|
||||
return (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpApp(o.appId)}
|
||||
aria-label={t("admin.dashboard.viewAppDetails", { name })}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${o.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">{name}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatOpenTimestamp(o.createdAt, lang)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<LoadMoreSection
|
||||
shownCount={opens.length}
|
||||
totalCount={data.totalCount}
|
||||
nextOffset={nextOffset}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMoreError={loadMoreError}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DrillInShell>
|
||||
);
|
||||
|
||||
@@ -521,6 +521,10 @@ export interface AdminAppOpensByApp {
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
opens: AppOpenByAppEntry[];
|
||||
}
|
||||
|
||||
@@ -559,6 +563,10 @@ export interface AdminAppOpensByUser {
|
||||
rangeFrom: string;
|
||||
rangeTo: string;
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
opens: AppOpenByUserEntry[];
|
||||
}
|
||||
|
||||
@@ -599,6 +607,15 @@ export type GetAdminAppOpensByAppParams = {
|
||||
range?: GetAdminAppOpensByAppRange;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminAppOpensByAppRange =
|
||||
@@ -615,6 +632,15 @@ export type GetAdminAppOpensByUserParams = {
|
||||
range?: GetAdminAppOpensByUserRange;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type GetAdminAppOpensByUserRange =
|
||||
|
||||
@@ -1078,6 +1078,21 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 100
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
responses:
|
||||
"200":
|
||||
description: Recent opens for the app
|
||||
@@ -1128,6 +1143,21 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 100
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
responses:
|
||||
"200":
|
||||
description: Recent app opens by the user
|
||||
@@ -2048,6 +2078,12 @@ components:
|
||||
type: string
|
||||
totalCount:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
nextOffset:
|
||||
type: ["integer", "null"]
|
||||
opens:
|
||||
type: array
|
||||
items:
|
||||
@@ -2064,6 +2100,9 @@ components:
|
||||
- rangeFrom
|
||||
- rangeTo
|
||||
- totalCount
|
||||
- limit
|
||||
- offset
|
||||
- nextOffset
|
||||
- opens
|
||||
|
||||
AppOpenByUserEntry:
|
||||
@@ -2120,6 +2159,12 @@ components:
|
||||
type: string
|
||||
totalCount:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
nextOffset:
|
||||
type: ["integer", "null"]
|
||||
opens:
|
||||
type: array
|
||||
items:
|
||||
@@ -2132,6 +2177,9 @@ components:
|
||||
- rangeFrom
|
||||
- rangeTo
|
||||
- totalCount
|
||||
- limit
|
||||
- offset
|
||||
- nextOffset
|
||||
- opens
|
||||
|
||||
TopUserItem:
|
||||
|
||||
@@ -1275,6 +1275,11 @@ export const GetAdminAppOpensByAppParams = zod.object({
|
||||
});
|
||||
|
||||
export const getAdminAppOpensByAppQueryRangeDefault = `7d`;
|
||||
export const getAdminAppOpensByAppQueryLimitDefault = 100;
|
||||
export const getAdminAppOpensByAppQueryLimitMax = 200;
|
||||
|
||||
export const getAdminAppOpensByAppQueryOffsetDefault = 0;
|
||||
export const getAdminAppOpensByAppQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminAppOpensByAppQueryParams = zod.object({
|
||||
range: zod
|
||||
@@ -1282,6 +1287,15 @@ export const GetAdminAppOpensByAppQueryParams = zod.object({
|
||||
.default(getAdminAppOpensByAppQueryRangeDefault),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminAppOpensByAppQueryLimitMax)
|
||||
.default(getAdminAppOpensByAppQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminAppOpensByAppQueryOffsetMin)
|
||||
.default(getAdminAppOpensByAppQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminAppOpensByAppResponse = zod.object({
|
||||
@@ -1296,6 +1310,9 @@ export const GetAdminAppOpensByAppResponse = zod.object({
|
||||
rangeFrom: zod.string(),
|
||||
rangeTo: zod.string(),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
opens: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
@@ -1317,6 +1334,11 @@ export const GetAdminAppOpensByUserParams = zod.object({
|
||||
});
|
||||
|
||||
export const getAdminAppOpensByUserQueryRangeDefault = `7d`;
|
||||
export const getAdminAppOpensByUserQueryLimitDefault = 100;
|
||||
export const getAdminAppOpensByUserQueryLimitMax = 200;
|
||||
|
||||
export const getAdminAppOpensByUserQueryOffsetDefault = 0;
|
||||
export const getAdminAppOpensByUserQueryOffsetMin = 0;
|
||||
|
||||
export const GetAdminAppOpensByUserQueryParams = zod.object({
|
||||
range: zod
|
||||
@@ -1324,6 +1346,15 @@ export const GetAdminAppOpensByUserQueryParams = zod.object({
|
||||
.default(getAdminAppOpensByUserQueryRangeDefault),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAdminAppOpensByUserQueryLimitMax)
|
||||
.default(getAdminAppOpensByUserQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAdminAppOpensByUserQueryOffsetMin)
|
||||
.default(getAdminAppOpensByUserQueryOffsetDefault),
|
||||
});
|
||||
|
||||
export const GetAdminAppOpensByUserResponse = zod.object({
|
||||
@@ -1337,6 +1368,9 @@ export const GetAdminAppOpensByUserResponse = zod.object({
|
||||
rangeFrom: zod.string(),
|
||||
rangeTo: zod.string(),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
opens: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
|
||||
Reference in New Issue
Block a user