From 5590f7d85ad22fc8ea0a64fdaf4a5ad8c5e6517a Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 4 Jun 2026 20:22:37 +0800 Subject: [PATCH] style: polish admin user leaderboard --- frontend-web/src/components/page-sidebar.tsx | 9 + .../workspace/workspace-nav-menu.tsx | 25 +- frontend-web/src/core/admin/api.ts | 12 +- frontend-web/src/core/admin/hooks.ts | 19 +- .../src/core/page-layout/sidebar-menu.ts | 8 - .../src/pages/AdminUserLeaderboardPage.tsx | 625 ++++++++++++------ .../app/gateway/routers/admin_users.py | 71 +- 7 files changed, 508 insertions(+), 261 deletions(-) diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 10512957..5cebcc9f 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -8,6 +8,7 @@ import { Folder, FolderOpen, Settings, + Trophy, User, Wrench, } from "lucide-react"; @@ -260,6 +261,14 @@ export function PageSidebar({ {t.settings.sections.tools} + {isAdmin && ( + navigate("/page/strategy/admin/user-leaderboard")} + > + + 用户排行榜 + + )} openSettings("account")}> 个人信息 diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 61597fcf..2c7c085f 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -15,9 +15,10 @@ import { SettingsIcon, ShieldCheckIcon, SparklesIcon, - TagsIcon, - UsersIcon, -} from "lucide-react"; + TagsIcon, + TrophyIcon, + UsersIcon, +} from "lucide-react"; import { useEffect, useState } from "react"; import { @@ -187,10 +188,20 @@ export function WorkspaceNavMenu() { 大模型调用统计 )} - {isAdmin && ( - navigate("/page/workspace/admin")} - > + {isAdmin && ( + + navigate("/page/strategy/admin/user-leaderboard") + } + > + + 用户排行榜 + + )} + {isAdmin && ( + navigate("/page/workspace/admin")} + > 用户中心 diff --git a/frontend-web/src/core/admin/api.ts b/frontend-web/src/core/admin/api.ts index e0622b57..a8fb2df9 100644 --- a/frontend-web/src/core/admin/api.ts +++ b/frontend-web/src/core/admin/api.ts @@ -156,11 +156,13 @@ export async function getUserMemory( } export async function getUserLeaderboard( - days: number, + range: { since: string; until: string; days?: number }, limit = 20, ): Promise { const params = new URLSearchParams({ - days: String(days), + days: String(range.days ?? 30), + since: range.since, + until: range.until, limit: String(limit), }); return parse( @@ -171,11 +173,13 @@ export async function getUserLeaderboard( export async function getUserAnalytics( userId: string, - days: number, + range: { since: string; until: string; days?: number }, limit = 20, ): Promise { const params = new URLSearchParams({ - days: String(days), + days: String(range.days ?? 30), + since: range.since, + until: range.until, limit: String(limit), }); return parse( diff --git a/frontend-web/src/core/admin/hooks.ts b/frontend-web/src/core/admin/hooks.ts index 1eb5ad36..b041f33f 100644 --- a/frontend-web/src/core/admin/hooks.ts +++ b/frontend-web/src/core/admin/hooks.ts @@ -15,17 +15,24 @@ import { updateAdminV1Section, } from "./api"; -export function useUserLeaderboard(days: number, limit = 20) { +export function useUserLeaderboard( + range: { since: string; until: string; days?: number }, + limit = 20, +) { return useQuery({ - queryKey: ["admin-activity", "leaderboard", days, limit], - queryFn: () => getUserLeaderboard(days, limit), + queryKey: ["admin-activity", "leaderboard", range.since, range.until, limit], + queryFn: () => getUserLeaderboard(range, limit), }); } -export function useUserAnalytics(userId: string | null, days: number, limit = 20) { +export function useUserAnalytics( + userId: string | null, + range: { since: string; until: string; days?: number }, + limit = 20, +) { return useQuery({ - queryKey: ["admin-activity", "analytics", userId, days, limit], - queryFn: () => getUserAnalytics(userId!, days, limit), + queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit], + queryFn: () => getUserAnalytics(userId!, range, limit), enabled: !!userId, }); } diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index c15ef50e..d2fe690a 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -35,7 +35,6 @@ import { ShieldCheck, Sparkles, Target, - Trophy, Users, } from "lucide-react"; @@ -260,13 +259,6 @@ export const sidebarMenuItems: MenuItem[] = [ path: "/page/strategy/admin/scheduled-tasks", adminOnly: true, }, - { - id: "mgmt-user-leaderboard", - label: "用户排行榜", - icon: Trophy, - path: "/page/strategy/admin/user-leaderboard", - adminOnly: true, - }, { id: "mgmt-user-activity", label: "用户活动审计", diff --git a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx index 906660c3..e0f37d09 100644 --- a/frontend-web/src/pages/AdminUserLeaderboardPage.tsx +++ b/frontend-web/src/pages/AdminUserLeaderboardPage.tsx @@ -2,8 +2,11 @@ import { Activity, + ArrowRight, + Award, BarChart3, - Clock3, + CalendarDays, + Crown, Loader2, Medal, MessageSquareText, @@ -22,13 +25,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { AdminLeaderboardQuestion, @@ -40,6 +36,62 @@ import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks"; import { cn } from "@/lib/utils"; type UserRankTab = "activity" | "questions" | "tokens"; +type RangeState = { since: string; until: string; days?: number }; +type GoogleAccent = "blue" | "red" | "yellow" | "green"; + +const GOOGLE_COLORS = ["#4285F4", "#DB4437", "#F4B400", "#0F9D58"] as const; + +const accentClasses: Record = { + blue: { + soft: "bg-[#E8F0FE] dark:bg-[#4285F4]/15", + text: "text-[#4285F4] dark:text-[#8AB4F8]", + bar: "bg-[#4285F4]", + border: "border-[#D2E3FC] dark:border-[#4285F4]/35", + }, + red: { + soft: "bg-[#FCE8E6] dark:bg-[#DB4437]/15", + text: "text-[#DB4437] dark:text-[#F28B82]", + bar: "bg-[#DB4437]", + border: "border-[#FAD2CF] dark:border-[#DB4437]/35", + }, + yellow: { + soft: "bg-[#FEF7E0] dark:bg-[#F4B400]/15", + text: "text-[#B06000] dark:text-[#FDD663]", + bar: "bg-[#F4B400]", + border: "border-[#FEEFC3] dark:border-[#F4B400]/35", + }, + green: { + soft: "bg-[#E6F4EA] dark:bg-[#0F9D58]/15", + text: "text-[#0F9D58] dark:text-[#81C995]", + bar: "bg-[#0F9D58]", + border: "border-[#CEEAD6] dark:border-[#0F9D58]/35", + }, +}; + +function toDateInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function defaultRange(days = 30): RangeState { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - days + 1); + return { since: toDateInput(start), until: toDateInput(end), days }; +} + +function rangeForApi(range: RangeState) { + const normalized = range.since && range.until ? range : defaultRange(30); + const start = new Date(`${normalized.since}T00:00:00`); + const end = new Date(`${normalized.until}T23:59:59`); + return { + since: start.toISOString(), + until: end.toISOString(), + days: Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1), + }; +} function fmtNumber(value?: number | null): string { return Number(value ?? 0).toLocaleString(); @@ -51,117 +103,198 @@ function fmtTime(value?: string | null): string { return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } -function scoreOf(user: AdminLeaderboardUser): number { - return user.run_count + user.tool_call_count; -} - function clampText(value: string, max = 120): string { const text = value.trim(); return text.length > max ? `${text.slice(0, max)}...` : text; } -function OverviewCard({ +function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number { + if (tab === "questions") return user.qa_count; + if (tab === "tokens") return user.total_tokens; + return user.run_count + user.tool_call_count; +} + +function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string { + if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`; + if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`; + return `${fmtNumber(userScore(user, tab))} 次使用`; +} + +function initials(user: AdminLeaderboardUser): string { + const source = user.email || user.user_id || "?"; + return source.slice(0, 1).toUpperCase(); +} + +function StatCard({ icon: Icon, label, value, - hint, + note, + accent, }: { icon: LucideIcon; label: string; value: string; - hint: string; + note: string; + accent: GoogleAccent; }) { + const color = accentClasses[accent]; return ( -
+
- {label} - + {label} + + +
-
{value}
-
{hint}
+
+ {value} +
+
{note}
); } -function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { - const max = Math.max(1, ...points.map((p) => p.runs)); - return ( -
- {points.length === 0 ? ( -
- 暂无趋势数据 -
- ) : ( - points.slice(-30).map((p) => ( -
- )) - )} -
- ); -} - -function UserRankList({ - users, - activeUserId, - onSelect, +function PodiumCard({ + user, + rank, tab, + active, + onSelect, }: { - users: AdminLeaderboardUser[]; - activeUserId: string | null; - onSelect: (user: AdminLeaderboardUser) => void; + user?: AdminLeaderboardUser; + rank: 1 | 2 | 3; tab: UserRankTab; + active: boolean; + onSelect: (user: AdminLeaderboardUser) => void; }) { - if (users.length === 0) { - return
暂无用户统计
; + const styles = { + 1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]", + 2: "border-[#D2E3FC] bg-[#E8F0FE] text-[#4285F4] dark:border-[#4285F4]/40 dark:bg-[#4285F4]/10 dark:text-[#8AB4F8]", + 3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]", + }[rank]; + + if (!user) { + return ( +
+
第 {rank} 名
+
暂无数据
+
+ ); } return ( -
+ + ); +} + +function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { + const max = Math.max(1, ...points.map((p) => p.runs)); + if (points.length === 0) { + return
暂无趋势数据
; + } + return ( +
+ {points.slice(-32).map((point, index) => ( +
+
+
+ ))} +
+ ); +} + +function UserTable({ + users, + tab, + selectedUserId, + onSelect, +}: { + users: AdminLeaderboardUser[]; + tab: UserRankTab; + selectedUserId: string | null; + onSelect: (user: AdminLeaderboardUser) => void; +}) { + const max = Math.max(1, ...users.map((user) => userScore(user, tab))); + if (users.length === 0) { + return
暂无用户统计
; + } + + return ( +
{users.map((user, index) => { - const main = - tab === "questions" - ? `${fmtNumber(user.qa_count)} 次问答` - : tab === "tokens" - ? `${fmtNumber(user.total_tokens)} tokens` - : `${fmtNumber(scoreOf(user))} 次使用`; + const score = userScore(user, tab); return ( @@ -171,7 +304,7 @@ function UserRankList({ ); } -function RankingTable({ +function SkillRanking({ title, icon: Icon, items, @@ -182,39 +315,54 @@ function RankingTable({ items: AdminLeaderboardSkill[]; empty: string; }) { + const max = Math.max(1, ...items.map((item) => item.call_count)); return ( -
-
- -

{title}

+
+
+
+ +

{title}

+
+ {items.length} 项
-
- {items.length === 0 && ( -
{empty}
- )} - {items.map((item, index) => ( -
- {index + 1} +
+ {items.length === 0 &&
{empty}
} + {items.slice(0, 10).map((item, index) => ( +
+ {index + 1}
-
{item.name}
-
- {fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)} +
+ {item.name} + + {fmtNumber(item.user_count)} 人 + +
+
+
-
-
{fmtNumber(item.call_count)}
-
- 成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)} +
+
+ {fmtNumber(item.call_count)} +
+
+ 失败 {fmtNumber(item.error_count)}
))}
-
+
); } -function QuestionList({ +function QuestionPanel({ title, items, showUser = true, @@ -224,19 +372,20 @@ function QuestionList({ showUser?: boolean; }) { return ( -
-
- -

{title}

+
+
+
+ +

{title}

+
+ {items.length}
-
- {items.length === 0 && ( -
暂无问题记录
- )} - {items.map((item, index) => ( +
+ {items.length === 0 &&
暂无问题记录
} + {items.slice(0, 8).map((item, index) => (
-
{clampText(item.text, 180)}
-
+
{clampText(item.text, 180)}
+
{showUser && {item.email || item.user_id || "未知用户"}} {fmtTime(item.asked_at)} {item.thread_id && 会话 {item.thread_id.slice(0, 8)}} @@ -244,32 +393,42 @@ function QuestionList({
))}
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{fmtNumber(value)}
); } function UserDetailPanel({ userId, - days, + range, onOpenAudit, }: { userId: string | null; - days: number; + range: ReturnType; onOpenAudit: (userId: string) => void; }) { - const { data, isLoading } = useUserAnalytics(userId, days, 12); + const { data, isLoading } = useUserAnalytics(userId, range, 12); if (!userId) { return ( -
- 点击排行榜中的用户查看个人统计 +
+ + 点击排行榜中的用户,查看个人问答、技能和趋势。
); } if (isLoading || !data) { return ( -
+
加载用户详情
@@ -278,15 +437,20 @@ function UserDetailPanel({ return ( -
-
+
+
-
{data.user.email || data.user.user_id}
-
最近活跃 {fmtTime(data.user.last_active_at)}
+
+ {data.user.email || data.user.user_id} +
+
+ 最近活跃 {fmtTime(data.user.last_active_at)} +
@@ -295,41 +459,33 @@ function UserDetailPanel({
-
+ -
-
- +
+
+ 个人使用趋势
- -
+ + - - + +
); } -function Metric({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
{fmtNumber(value)}
-
- ); -} - export default function AdminUserLeaderboardPage() { const navigate = useNavigate(); - const [days, setDays] = useState(30); + const [range, setRange] = useState(() => defaultRange(30)); const [tab, setTab] = useState("activity"); const [query, setQuery] = useState(""); const [selectedUserId, setSelectedUserId] = useState(null); - const { data, isLoading, error } = useUserLeaderboard(days, 30); + const apiRange = useMemo(() => rangeForApi(range), [range]); + const { data, isLoading, error } = useUserLeaderboard(apiRange, 30); - const users = useMemo(() => { + const rankedUsers = useMemo(() => { const source = tab === "questions" ? data?.users_by_questions @@ -343,122 +499,169 @@ export default function AdminUserLeaderboardPage() { }, [data, query, tab]); const overview = data?.overview; + const topUsers = rankedUsers.slice(0, 3); + + const setQuickRange = (days: number) => { + setRange(defaultRange(days)); + }; return ( -
+
-
-
+
+
-

用户排行榜

-

- 汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。 +

+ + 管理员分析 +
+

用户排行榜

+

+ 统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。

- + +
+
+ + setRange((prev) => ({ ...prev, since: event.target.value }))} + className="h-9 w-36 bg-white dark:bg-[#0B2A50]" + /> +
+
+ + setRange((prev) => ({ ...prev, until: event.target.value }))} + className="h-9 w-36 bg-white dark:bg-[#0B2A50]" + /> +
+ + + +
-
+
{isLoading && ( -
+
加载统计数据
)} + {!isLoading && error && ( -
+
加载失败:{error.message}
)} + {!isLoading && data && overview && ( <>
- - - - + + + +
-
-
-
+
+
+
+

榜单前三

+

根据当前榜单维度实时切换。

+
+ setTab(value as UserRankTab)}> + + 使用排行 + 问答排行 + 消耗排行 + + +
+
+ setSelectedUserId(user.user_id)} /> + setSelectedUserId(user.user_id)} /> + setSelectedUserId(user.user_id)} /> +
+
+ +
+
+
- -

用户排行

+ +

完整排行榜

-
-
- - setQuery(event.target.value)} - placeholder="搜索用户" - className="h-9 w-52 pl-8" - /> -
- setTab(value as UserRankTab)}> - - 使用 - 问答 - 消耗 - - +
+ + setQuery(event.target.value)} + placeholder="搜索用户邮箱或 ID" + className="h-9 w-64 bg-white pl-8 dark:bg-[#0B2A50]" + />
- setSelectedUserId(user.user_id)} /> -
+
-
-
- -

用户详情

+
+
+ +

用户详情

-
+
{ navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`); }} />
+
+
+ +
+
+
+ +

时间趋势

+
+
+ + {range.since} 至 {range.until} +
+ +
+ +
+ +
-
-
- - 时间趋势 -
- -
- -
- - -
- -
- - +
+ +
)} diff --git a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py index b60f1618..723fe09d 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/admin_users.py +++ b/offline-backend-20260512/backend/app/gateway/routers/admin_users.py @@ -146,8 +146,27 @@ def _iso(value) -> str: return value.isoformat() if isinstance(value, datetime) else str(value or "") -def _range_start(days: int) -> datetime: - return _now() - timedelta(days=max(1, min(days, 365))) +def _parse_datetime(value: str | None, fallback: datetime) -> datetime: + if not value: + return fallback + text = value.strip() + if not text: + return fallback + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + return datetime.fromisoformat(text) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc + + +def _resolve_range(days: int, since: str | None, until: str | None) -> tuple[datetime, datetime, int]: + end = _parse_datetime(until, _now()) + start = _parse_datetime(since, end - timedelta(days=max(1, min(days, 365)))) + if start > end: + raise HTTPException(status_code=400, detail="since must be before until") + span_days = max(1, min(365, (end - start).days + 1)) + return start, end, span_days def _question_count_expr(): @@ -403,6 +422,8 @@ async def _query_overview(session, since: datetime, until: datetime, *, user_id: async def get_user_leaderboard( request: Request, days: int = Query(default=30, ge=1, le=365), + since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"), + until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"), limit: int = Query(default=20, ge=5, le=100), ) -> AdminLeaderboardResponse: """Return admin analytics for user activity, Q&A, skills, questions, and trends.""" @@ -411,21 +432,20 @@ async def get_user_leaderboard( if session_factory is None: raise HTTPException(status_code=503, detail="Analytics requires database persistence") - until = _now() - since = _range_start(days) + since_dt, until_dt, range_days = _resolve_range(days, since, until) async with session_factory() as session: - overview = await _query_overview(session, since, until) - users = await _query_user_leaderboard(session, since, until) - skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit) - tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit) - recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit) - frequent_questions = await _query_frequent_questions(session, since, until, limit=limit) - trends = await _query_trends(session, since, until) + overview = await _query_overview(session, since_dt, until_dt) + users = await _query_user_leaderboard(session, since_dt, until_dt) + skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit) + tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit) + recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit) + frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit) + trends = await _query_trends(session, since_dt, until_dt) return AdminLeaderboardResponse( - range_days=days, - since=_iso(since), - until=_iso(until), + range_days=range_days, + since=_iso(since_dt), + until=_iso(until_dt), overview=overview, users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit], users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit], @@ -443,6 +463,8 @@ async def get_user_analytics( user_id: str, request: Request, days: int = Query(default=30, ge=1, le=365), + since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"), + until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"), limit: int = Query(default=20, ge=5, le=100), ) -> AdminUserAnalyticsResponse: """Return one user's admin analytics profile.""" @@ -451,22 +473,21 @@ async def get_user_analytics( if session_factory is None: raise HTTPException(status_code=503, detail="Analytics requires database persistence") - until = _now() - since = _range_start(days) + since_dt, until_dt, range_days = _resolve_range(days, since, until) async with session_factory() as session: - users = await _query_user_leaderboard(session, since, until, user_id=user_id) + users = await _query_user_leaderboard(session, since_dt, until_dt, user_id=user_id) if not users: raise HTTPException(status_code=404, detail="User not found") - overview = await _query_overview(session, since, until, user_id=user_id) - skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit) - tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit) - recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit) - trends = await _query_trends(session, since, until, user_id=user_id) + overview = await _query_overview(session, since_dt, until_dt, user_id=user_id) + skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit) + tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit) + recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit) + trends = await _query_trends(session, since_dt, until_dt, user_id=user_id) return AdminUserAnalyticsResponse( - range_days=days, - since=_iso(since), - until=_iso(until), + range_days=range_days, + since=_iso(since_dt), + until=_iso(until_dt), user=users[0], overview=overview, skills=skills,