Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/agent-signups/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import AgentSignupsPage from "@/components/AgentSignups/AgentSignupsPage";

export const metadata: Metadata = {
title: "Agent Sign-Ups — Recoup Admin",
};

export default function Page() {
return <AgentSignupsPage />;
}
73 changes: 73 additions & 0 deletions components/AgentSignups/AgentSignupsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { useState } from "react";
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import { useAgentSignups } from "@/hooks/useAgentSignups";
import AgentSignupsTable from "@/components/AgentSignups/AgentSignupsTable";
import AgentSignupsStats from "@/components/AgentSignups/AgentSignupsStats";
import PeriodSelector from "@/components/Admin/PeriodSelector";
import AdminLineChart from "@/components/Admin/AdminLineChart";
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
import { getSignupsByDate } from "@/lib/agent-signups/getSignupsByDate";
import type { AdminPeriod } from "@/types/admin";

export default function AgentSignupsPage() {
const [period, setPeriod] = useState<AdminPeriod>("all");
const { data, isLoading, error } = useAgentSignups(period);

const signupsByDate = data ? getSignupsByDate(data.signups) : [];

return (
<main className="mx-auto max-w-6xl px-4 py-10">
<div className="mb-6 flex items-start justify-between">
<div>
<PageBreadcrumb current="Agent Sign-Ups" />
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Agent Sign-Ups
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
API key sign-ups from AI agents, grouped by time period.
</p>
</div>
<ApiDocsLink path="admins/agent-signups" />
</div>

<div className="mb-6 flex items-center gap-4">
<PeriodSelector period={period} onPeriodChange={setPeriod} />
{data && <AgentSignupsStats data={data} />}
</div>

{isLoading && (
<>
<ChartSkeleton />
<TableSkeleton columns={["Email", "Key Name", "Created At"]} />
</>
)}

{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error instanceof Error ? error.message : "Failed to load agent sign-ups"}
</div>
)}

{!isLoading && !error && data && data.signups.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No agent sign-ups found for this period.
</div>
)}

{!isLoading && !error && data && data.signups.length > 0 && (
<>
<AdminLineChart
title="Agent Sign-Ups Over Time"
data={signupsByDate.map((d) => ({ date: d.date, count: d.count }))}
label="Sign-Ups"
/>
<AgentSignupsTable signups={data.signups} />
</>
)}
</main>
);
}
15 changes: 15 additions & 0 deletions components/AgentSignups/AgentSignupsStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { AgentSignupsResponse } from "@/types/agentSignups";

interface AgentSignupsStatsProps {
data: AgentSignupsResponse;
}

export default function AgentSignupsStats({ data }: AgentSignupsStatsProps) {
return (
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.total}</span> sign-ups
</span>
</div>
);
}
78 changes: 78 additions & 0 deletions components/AgentSignups/AgentSignupsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

This entire file is a copy-paste of PrivyLoginsTable.tsx (and ContentSlackTable.tsx, etc.) with only entity names changed. Extract a shared generic DataTable component that accepts data, columns, and a default sort field — then all 6+ copies collapse to one-line usages.

(Based on your team's feedback about preferring shared components over duplicate wrappers.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/AgentSignups/AgentSignupsTable.tsx, line 26:

<comment>This entire file is a copy-paste of `PrivyLoginsTable.tsx` (and `ContentSlackTable.tsx`, etc.) with only entity names changed. Extract a shared generic `DataTable` component that accepts `data`, `columns`, and a default sort field — then all 6+ copies collapse to one-line usages.

(Based on your team's feedback about preferring shared components over duplicate wrappers.) </comment>

<file context>
@@ -0,0 +1,78 @@
+  signups: AgentSignup[];
+}
+
+export default function AgentSignupsTable({ signups }: AgentSignupsTableProps) {
+  const [sorting, setSorting] = useState<SortingState>([
+    { id: "created_at", desc: true },
</file context>
Fix with Cubic


import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { agentSignupsColumns } from "@/components/AgentSignups/agentSignupsColumns";
import type { AgentSignup } from "@/types/agentSignups";

interface AgentSignupsTableProps {
signups: AgentSignup[];
}

export default function AgentSignupsTable({ signups }: AgentSignupsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);

const table = useReactTable({
data: signups,
columns: agentSignupsColumns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});

return (
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={agentSignupsColumns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
31 changes: 31 additions & 0 deletions components/AgentSignups/agentSignupsColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type ColumnDef } from "@tanstack/react-table";
import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
import type { AgentSignup } from "@/types/agentSignups";

export const agentSignupsColumns: ColumnDef<AgentSignup>[] = [
{
id: "email",
accessorKey: "email",
header: ({ column }) => <SortableHeader column={column} label="Email" />,
cell: ({ getValue }) => (
<span className="font-medium">{getValue<string>()}</span>
),
},
{
id: "name",
accessorKey: "name",
header: "Key Name",
cell: ({ getValue }) => (
<span className="max-w-xs truncate" title={getValue<string>()}>
{getValue<string>()}
</span>
),
},
{
id: "created_at",
accessorKey: "created_at",
header: ({ column }) => <SortableHeader column={column} label="Created At" />,
cell: ({ getValue }) => new Date(getValue<string>()).toLocaleString(),
sortingFn: "datetime",
Comment on lines +25 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In @tanstack/react-tablev8, does built-insortingFn: "datetime"require accessor values to beDate objects, or is ISO date string input officially supported?

💡 Result:

No, the built-in sortingFn: "datetime" in @tanstack/react-table v8 requires accessor values to be Date objects. ISO date strings are not officially supported by this sorting function. The official TanStack Table v8 documentation explicitly states: "datetime - Sorts by time, use this if your values are Date objects." This appears consistently across the Sorting Guide and Sorting APIs documentation. Evidence from community discussions and issues confirms this: - Users report errors like "a.getTime is not a function" when using strings with datetime sortingFn, indicating it calls .getTime expecting Date objects. - Recommended pattern: Keep raw Date objects as accessor values for sorting, and format them only in the cell renderer (e.g., using accessorKey: 'dateField', sortingFn: 'datetime', cell: ({ value }) => formatDate(value)). - Auto-detection in getAutoSortingFn checks Object.prototype.toString.call(value) === '[object Date]' before selecting sortingFns.datetime. - For strings (including ISO), it falls back to text/alphanumeric/basic sorting, which works correctly for ISO strings due to lexical sorting properties but is not the datetime fn. While ISO strings may sort correctly with other fns like basic (due to ISO format), using sortingFn: "datetime" with strings will fail or behave unexpectedly, as it is designed exclusively for Date objects.

Citations:


🏁 Script executed:

# Find package.json or similar to check TanStack version
find . -name "package.json" -o -name "package-lock.json" -o -name "yarn.lock" | head -5

Repository: recoupable/admin

Length of output: 389


🏁 Script executed:

# Check for `@tanstack/react-table` dependency
rg "@tanstack/react-table" --max-count 5 -A 1

Repository: recoupable/admin

Length of output: 3064


🏁 Script executed:

# Search for other uses of accessorFn in the codebase to verify the pattern
rg "accessorFn:" -B 2 -A 2

Repository: recoupable/admin

Length of output: 1224


🏁 Script executed:

# Read the agentSignupsColumns.tsx file to see the full context
cat -n components/AgentSignups/agentSignupsColumns.tsx

Repository: recoupable/admin

Length of output: 1232


🏁 Script executed:

# Check the AgentSignup type definition to confirm created_at is a string
rg "type AgentSignup" -A 10

Repository: recoupable/admin

Length of output: 538


Fix datetime sorting with Date accessor, not string accessor.

The sortingFn: "datetime" on line 29 requires Date objects but receives string values from accessorKey: "created_at" (line 26). TanStack Table v8's datetime sorter calls .getTime() on values, which will fail for strings. Use accessorFn to convert the string to a Date object for correct sorting:

Corrected column definition
   {
     id: "created_at",
-    accessorKey: "created_at",
+    accessorFn: (row) => new Date(row.created_at),
     header: ({ column }) => <SortableHeader column={column} label="Created At" />,
-    cell: ({ getValue }) => new Date(getValue<string>()).toLocaleString(),
+    cell: ({ row }) => new Date(row.original.created_at).toLocaleString(),
     sortingFn: "datetime",
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/AgentSignups/agentSignupsColumns.tsx` around lines 25 - 29, The
column uses accessorKey: "created_at" (producing strings) but declares
sortingFn: "datetime" which expects Date objects; change the column to keep id:
"created_at" but replace accessorKey with accessorFn that parses the row value
into a Date (e.g., new Date(row.created_at)) so the sorter can call .getTime();
update the cell renderer to treat getValue as a Date (call getValue<Date>() and
then .toLocaleString()) and keep header as SortableHeader to preserve UI
behavior.

},
];
1 change: 1 addition & 0 deletions components/Home/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function AdminDashboard() {
<NavButton href="/privy" label="View Privy Logins" />
<NavButton href="/coding" label="Coding Agent Tags" />
<NavButton href="/content" label="View Content Agent" />
<NavButton href="/agent-signups" label="Agent Sign-Ups" />
</nav>
</div>
);
Expand Down
20 changes: 20 additions & 0 deletions hooks/useAgentSignups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { fetchAgentSignups } from "@/lib/recoup/fetchAgentSignups";
import type { AdminPeriod } from "@/types/admin";

export function useAgentSignups(period: AdminPeriod) {
const { ready, authenticated, getAccessToken } = usePrivy();

return useQuery({
queryKey: ["admin", "agent-signups", period],
queryFn: async () => {
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchAgentSignups(token, period);
},
enabled: ready && authenticated,
});
}
22 changes: 22 additions & 0 deletions lib/agent-signups/getSignupsByDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AgentSignup } from "@/types/agentSignups";

export interface SignupsByDateEntry {
date: string;
count: number;
}

/**
* Aggregates agent sign-ups by UTC date (YYYY-MM-DD) for charting.
*/
export function getSignupsByDate(signups: AgentSignup[]): SignupsByDateEntry[] {
const counts: Record<string, number> = {};

for (const signup of signups) {
const date = signup.created_at.slice(0, 10);
counts[date] = (counts[date] || 0) + 1;
}

return Object.entries(counts)
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
}
22 changes: 22 additions & 0 deletions lib/agent-signups/getSignupsByEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AgentSignup } from "@/types/agentSignups";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

This function is dead code referencing a non-existent pie chart. The JSDoc claims it's "for pie chart display" but no pie chart exists in the codebase, and the PR scope is a line chart + data table. The function itself is never imported anywhere. Remove this file or, if the aggregation is actually needed, correct the comment and wire it into the page.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agent-signups/getSignupsByEmail.ts, line 11:

<comment>This function is dead code referencing a non-existent pie chart. The JSDoc claims it's "for pie chart display" but no pie chart exists in the codebase, and the PR scope is a line chart + data table. The function itself is never imported anywhere. Remove this file or, if the aggregation is actually needed, correct the comment and wire it into the page.</comment>

<file context>
@@ -0,0 +1,22 @@
+/**
+ * Aggregates agent sign-ups by email address for pie chart display.
+ */
+export function getSignupsByEmail(signups: AgentSignup[]): SignupsByEmailEntry[] {
+  const counts: Record<string, number> = {};
+
</file context>
Fix with Cubic


export interface SignupsByEmailEntry {
name: string;
value: number;
}

/**
* Aggregates agent sign-ups by email address for pie chart display.
*/
export function getSignupsByEmail(signups: AgentSignup[]): SignupsByEmailEntry[] {
const counts: Record<string, number> = {};

for (const signup of signups) {
const key = signup.email || "Unknown";
counts[key] = (counts[key] || 0) + 1;
}

return Object.entries(counts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
22 changes: 22 additions & 0 deletions lib/recoup/fetchAgentSignups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { API_BASE_URL } from "@/lib/consts";
import type { AdminPeriod } from "@/types/admin";
import type { AgentSignupsResponse } from "@/types/agentSignups";

export async function fetchAgentSignups(
accessToken: string,
period: AdminPeriod,
): Promise<AgentSignupsResponse> {
const url = new URL(`${API_BASE_URL}/api/admins/agent-signups`);
url.searchParams.set("period", period);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

return res.json();
}
12 changes: 12 additions & 0 deletions types/agentSignups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type AgentSignup = {
id: string;
name: string;
email: string;
created_at: string;
};

export type AgentSignupsResponse = {
status: "success" | "error";
total: number;
signups: AgentSignup[];
};
Loading