diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx index 29fc013bddc..abc3e2eaf87 100644 --- a/ui/src/pages/UserProfile.tsx +++ b/ui/src/pages/UserProfile.tsx @@ -1,30 +1,25 @@ import { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { - Activity, - AlertCircle, - BarChart3, - CheckCircle2, - Clock3, - Coins, - Hash, - MessageSquareText, - Ticket, - UserRound, -} from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { AlertCircle, UserRound } from "lucide-react"; import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared"; import { Link, useParams } from "@/lib/router"; import { userProfilesApi } from "../api/userProfiles"; import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar"; -import { Badge } from "../components/ui/badge"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { StatusBadge } from "../components/StatusBadge"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; -import { cn, formatCents, formatDate, formatShortDate, formatTokens, issueUrl, providerDisplayName, relativeTime } from "../lib/utils"; +import { + formatCents, + formatDate, + formatShortDate, + formatTokens, + issueUrl, + providerDisplayName, + relativeTime, +} from "../lib/utils"; const NO_COMPANY = "__none__"; @@ -44,109 +39,123 @@ function completionRate(stats: UserProfileWindowStats) { return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`; } -function StatPill({ - icon: Icon, - label, - value, - tone = "default", -}: { - icon: LucideIcon; - label: string; - value: string; - tone?: "default" | "green" | "amber" | "blue"; -}) { +function HeroStat({ label, value, hint }: { label: string; value: string; hint?: string }) { return ( -
-
- - {label} -
-
{value}
+
+
{value}
+
{label}
+ {hint ?
{hint}
: null}
); } -function WindowPanel({ stats }: { stats: UserProfileWindowStats }) { +function WindowColumn({ stats }: { stats: UserProfileWindowStats }) { + const tokens = totalTokens(stats); return ( -
-
-

{stats.label}

- {formatTokens(totalTokens(stats))} +
+
+

{stats.label}

+ {completionRate(stats)} done
-
- - - - + +
+ + + +
-
-
-
Created
-
{stats.createdIssues}
-
-
-
Open
-
{stats.assignedOpenIssues}
-
-
-
Rate
-
{completionRate(stats)}
-
+ +
+ Tokens + {formatTokens(tokens)} + Spend + {formatCents(stats.costCents)} + Created + {stats.createdIssues} + Open + {stats.assignedOpenIssues}
); } +function Metric({ value, label }: { value: string; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + function UsageChart({ points }: { points: UserProfileDailyPoint[] }) { - const maxTokens = Math.max(1, ...points.map((point) => totalTokens(point))); - const maxActivity = Math.max(1, ...points.map((point) => point.activityCount + point.completedIssues)); + const totals = points.map((point) => totalTokens(point)); + const maxTokens = Math.max(1, ...totals); + const maxCompleted = Math.max(1, ...points.map((point) => point.completedIssues)); + const totalTokensSum = totals.reduce((sum, value) => sum + value, 0); return ( -
-
-
-

Two-week signal

-

Token pressure, user actions, and completed work by day.

-
-
- tokens - activity +
+
+

Last 14 days

+
+ {formatTokens(totalTokensSum)} + tokens total
-
+
{points.map((point) => { - const tokenHeight = Math.max(6, Math.round((totalTokens(point) / maxTokens) * 100)); - const activityHeight = Math.max(4, Math.round(((point.activityCount + point.completedIssues) / maxActivity) * 72)); + const tokens = totalTokens(point); + const heightPct = tokens === 0 ? 0 : Math.max(2, Math.round((tokens / maxTokens) * 100)); + const completedPct = point.completedIssues === 0 + ? 0 + : Math.max(8, Math.round((point.completedIssues / maxCompleted) * 36)); return ( -
-
+
+
+ {completedPct > 0 ? (
-
-
-
{new Date(point.date).getUTCDate()}
+ ) : null}
); })}
+
+ {points.map((point, index) => ( +
+ {index === 0 || index === 6 || index === 13 ? formatShortDate(point.date) : null} +
+ ))} +
+
+ + tokens / day + + + completions + +
); } +interface UsageRow { + key: string; + label: string; + sublabel: string; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + function UsageList({ title, empty, @@ -154,35 +163,32 @@ function UsageList({ }: { title: string; empty: string; - rows: Array<{ - key: string; - label: string; - sublabel: string; - costCents: number; - inputTokens: number; - cachedInputTokens: number; - outputTokens: number; - }>; + rows: UsageRow[]; }) { return ( -
-

{title}

-
- {rows.length === 0 ? ( -
{empty}
- ) : rows.map((row) => ( -
-
-
{row.label}
-
{row.sublabel}
-
-
- {formatTokens(totalTokens(row))} - {formatCents(row.costCents)} -
-
- ))} +
+
+

{title}

+ {rows.length}
+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
    + {rows.map((row) => ( +
  • +
    +
    {row.label}
    +
    {row.sublabel}
    +
    +
    + {formatTokens(totalTokens(row))} + {formatCents(row.costCents)} +
    +
  • + ))} +
+ )}
); } @@ -207,12 +213,12 @@ export function UserProfile() { const last7 = data?.stats.find((entry) => entry.key === "last7"); const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User"; - const agentUsageRows = useMemo( + const agentUsageRows = useMemo( () => (data?.topAgents ?? []).map((row) => ({ key: row.agentId, label: row.agentName ?? row.agentId.slice(0, 8), - sublabel: "Issue-linked token usage", + sublabel: "Issue-linked usage", costCents: row.costCents, inputTokens: row.inputTokens, cachedInputTokens: row.cachedInputTokens, @@ -221,7 +227,7 @@ export function UserProfile() { [data?.topAgents], ); - const providerUsageRows = useMemo( + const providerUsageRows = useMemo( () => (data?.topProviders ?? []).map((row) => ({ key: `${row.provider}:${row.biller}:${row.model}`, @@ -247,123 +253,103 @@ export function UserProfile() { return ; } + const allTimeTokens = allTime ? totalTokens(allTime) : 0; + const metaParts = [ + data.user.membershipRole ?? "member", + data.user.membershipStatus, + `joined ${formatDate(data.user.joinedAt)}`, + ]; + return ( -
-
-
-
-
- - {data.user.image ? : null} - {initials(displayName)} - -
-
-

{displayName}

- @{data.user.slug} -
-
- {data.user.email ? {data.user.email} : null} - {data.user.membershipRole ?? "member"} - {data.user.membershipStatus} - joined {formatDate(data.user.joinedAt)} -
-
+
+
+
+ + {data.user.image ? : null} + {initials(displayName)} + +
+
+

{displayName}

+ @{data.user.slug}
- -
- - - - -
-
- -
-
- - Profile ledger -
-
-
- All-time spend - {formatCents(allTime?.costCents ?? 0)} -
-
- Attributed cost events - {allTime?.costEventCount ?? 0} -
-
- Comments - {allTime?.commentCount ?? 0} -
-
- Completion rate - {allTime ? completionRate(allTime) : "0%"} -
+
+ {data.user.email ? {data.user.email} : null} + {data.user.email ? · : null} + {metaParts.join(" · ")}
+ +
+ + + + +
-
- {data.stats.map((entry) => )} -
+
+ {data.stats.map((entry) => )} +
-
-
-
-

Recent tasks

- {data.recentIssues.length} -
-
- {data.recentIssues.length === 0 ? ( -
No touched tasks yet.
- ) : data.recentIssues.map((issue) => ( - -
-
{issue.identifier ?? issue.id.slice(0, 8)}
-
{issue.title}
-
-
- - {relativeTime(issue.updatedAt)} -
- - ))} +
+
+
+

Recent tasks

+ {data.recentIssues.length}
+ {data.recentIssues.length === 0 ? ( +
No touched tasks yet.
+ ) : ( +
    + {data.recentIssues.map((issue) => ( +
  • + + {issue.identifier ?? issue.id.slice(0, 8)} + {issue.title} + + + {relativeTime(issue.updatedAt)} + + +
  • + ))} +
+ )}
-
-
-

Recent activity

- {data.recentActivity.length} +
+
+

Recent activity

+ {data.recentActivity.length}
-
- {data.recentActivity.length === 0 ? ( -
No direct user actions recorded yet.
- ) : data.recentActivity.map((event) => ( -
-
-
{event.action.replaceAll("_", " ")}
-
- {event.entityType} - {event.entityId.slice(0, 12)} + {data.recentActivity.length === 0 ? ( +
No direct user actions recorded yet.
+ ) : ( +
    + {data.recentActivity.map((event) => ( +
  • +
    +
    {event.action.replaceAll("_", " ")}
    +
    + {event.entityType} · {event.entityId.slice(0, 12)} +
    -
-
{relativeTime(event.createdAt)}
-
- ))} -
+ {relativeTime(event.createdAt)} + + ))} + + )}
-
+