Skip to content

Commit 03ee6dd

Browse files
sweetmantechRecoup Agentclaude
authored
agent: @U0AJM7X8FBR Admin + Docs + API - we want a new admin page to view analy (#20)
* progress: admin - add Coding Agent Slack Tags analytics page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: update endpoint path to /api/admins/coding/slack Aligns with the simplified endpoint path from the docs PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: address PR review — DRY period type, shared components - Move route from /coding-agent to /coding (KISS) - Create shared AdminPeriod type in types/admin.ts (DRY) - Rename PrivyPeriodSelector to PeriodSelector in components/Admin/ (DRY) - Extract AdminLineChart shared component (DRY) - Update PrivyLoginsPage and PrivyLastSeenChart to use shared components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove SlackTagsChart and PrivyLastSeenChart wrappers Use AdminLineChart directly in both pages instead of thin wrappers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Recoup Agent <agent@recoupable.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 986f25c commit 03ee6dd

File tree

14 files changed

+339
-31
lines changed

14 files changed

+339
-31
lines changed

app/coding/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from "next";
2+
import CodingAgentSlackTagsPage from "@/components/CodingAgentSlackTags/CodingAgentSlackTagsPage";
3+
4+
export const metadata: Metadata = {
5+
title: "Coding Agent Slack Tags — Recoup Admin",
6+
};
7+
8+
export default function Page() {
9+
return <CodingAgentSlackTagsPage />;
10+
}

components/PrivyLogins/PrivyLastSeenChart.tsx renamed to components/Admin/AdminLineChart.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,26 @@ import {
77
ChartTooltipContent,
88
type ChartConfig,
99
} from "@/components/ui/chart";
10-
import type { PrivyUser } from "@/types/privy";
11-
import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate";
1210

13-
const chartConfig = {
14-
count: {
15-
label: "Last Seen",
16-
color: "#345A5D",
17-
},
18-
} satisfies ChartConfig;
19-
20-
interface PrivyLastSeenChartProps {
21-
logins: PrivyUser[];
11+
interface AdminLineChartProps {
12+
title: string;
13+
data: Array<{ date: string; count: number }>;
14+
label?: string;
2215
}
2316

24-
export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps) {
25-
const data = getLastSeenByDate(logins);
26-
17+
export default function AdminLineChart({ title, data, label = "Count" }: AdminLineChartProps) {
2718
if (data.length === 0) return null;
2819

20+
const chartConfig = {
21+
count: {
22+
label,
23+
color: "#345A5D",
24+
},
25+
} satisfies ChartConfig;
26+
2927
return (
3028
<div className="mb-6 rounded-lg border p-4">
31-
<h2 className="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">
32-
Last Seen Activity
33-
</h2>
29+
<h2 className="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">{title}</h2>
3430
<ChartContainer config={chartConfig} className="h-[250px] w-full">
3531
<LineChart data={data} accessibilityLayer>
3632
<CartesianGrid vertical={false} />
@@ -50,7 +46,11 @@ export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps)
5046
<ChartTooltipContent
5147
labelFormatter={(value) => {
5248
const d = new Date(String(value) + "T00:00:00");
53-
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
49+
return d.toLocaleDateString("en-US", {
50+
month: "long",
51+
day: "numeric",
52+
year: "numeric",
53+
});
5454
}}
5555
/>
5656
}

components/PrivyLogins/PrivyPeriodSelector.tsx renamed to components/Admin/PeriodSelector.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import type { PrivyLoginsPeriod } from "@/types/privy";
1+
import type { AdminPeriod } from "@/types/admin";
22

3-
const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [
3+
const PERIODS: { value: AdminPeriod; label: string }[] = [
44
{ value: "all", label: "All Time" },
55
{ value: "daily", label: "Daily" },
66
{ value: "weekly", label: "Weekly" },
77
{ value: "monthly", label: "Monthly" },
88
];
99

10-
interface PrivyPeriodSelectorProps {
11-
period: PrivyLoginsPeriod;
12-
onPeriodChange: (period: PrivyLoginsPeriod) => void;
10+
interface PeriodSelectorProps {
11+
period: AdminPeriod;
12+
onPeriodChange: (period: AdminPeriod) => void;
1313
}
1414

15-
export default function PrivyPeriodSelector({ period, onPeriodChange }: PrivyPeriodSelectorProps) {
15+
export default function PeriodSelector({ period, onPeriodChange }: PeriodSelectorProps) {
1616
return (
1717
<div className="flex rounded-lg border bg-white dark:bg-gray-900 overflow-hidden">
1818
{PERIODS.map(({ value, label }) => (
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
5+
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
6+
import { useSlackTags } from "@/hooks/useSlackTags";
7+
import SlackTagsTable from "./SlackTagsTable";
8+
import AdminLineChart from "@/components/Admin/AdminLineChart";
9+
import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate";
10+
import PeriodSelector from "@/components/Admin/PeriodSelector";
11+
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
12+
import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
13+
import type { AdminPeriod } from "@/types/admin";
14+
15+
export default function CodingAgentSlackTagsPage() {
16+
const [period, setPeriod] = useState<AdminPeriod>("all");
17+
const { data, isLoading, error } = useSlackTags(period);
18+
19+
return (
20+
<main className="mx-auto max-w-6xl px-4 py-10">
21+
<div className="mb-6 flex items-start justify-between">
22+
<div>
23+
<PageBreadcrumb current="Coding Agent Tags" />
24+
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
25+
Coding Agent Slack Tags
26+
</h1>
27+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
28+
Slack mentions of the Recoup Coding Agent, pulled directly from the Slack API.
29+
</p>
30+
</div>
31+
<ApiDocsLink path="admins/coding-agent-slack-tags" />
32+
</div>
33+
34+
<div className="mb-6 flex items-center gap-4">
35+
<PeriodSelector period={period} onPeriodChange={setPeriod} />
36+
{data && (
37+
<div className="text-sm text-gray-600 dark:text-gray-400">
38+
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.total}</span>{" "}
39+
{data.total === 1 ? "tag" : "tags"} found
40+
</div>
41+
)}
42+
</div>
43+
44+
{isLoading && (
45+
<>
46+
<ChartSkeleton />
47+
<TableSkeleton columns={["Tagged By", "Prompt", "Channel", "Timestamp"]} />
48+
</>
49+
)}
50+
51+
{error && (
52+
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
53+
{error instanceof Error ? error.message : "Failed to load Slack tags"}
54+
</div>
55+
)}
56+
57+
{!isLoading && !error && data && data.tags.length === 0 && (
58+
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
59+
No tags found for this period.
60+
</div>
61+
)}
62+
63+
{!isLoading && !error && data && data.tags.length > 0 && (
64+
<>
65+
<AdminLineChart title="Tags Over Time" data={getTagsByDate(data.tags)} label="Tags" />
66+
<SlackTagsTable tags={data.tags} />
67+
</>
68+
)}
69+
</main>
70+
);
71+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { type ColumnDef } from "@tanstack/react-table";
2+
import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
3+
import type { SlackTag } from "@/types/coding-agent";
4+
5+
export const slackTagsColumns: ColumnDef<SlackTag>[] = [
6+
{
7+
id: "user_name",
8+
accessorKey: "user_name",
9+
header: "Tagged By",
10+
cell: ({ row }) => {
11+
const tag = row.original;
12+
return (
13+
<div className="flex items-center gap-2">
14+
{tag.user_avatar && (
15+
<img
16+
src={tag.user_avatar}
17+
alt={tag.user_name}
18+
className="h-6 w-6 rounded-full"
19+
/>
20+
)}
21+
<span className="font-medium">{tag.user_name}</span>
22+
</div>
23+
);
24+
},
25+
},
26+
{
27+
id: "prompt",
28+
accessorKey: "prompt",
29+
header: "Prompt",
30+
cell: ({ getValue }) => (
31+
<span className="max-w-md truncate block text-sm text-gray-700 dark:text-gray-300">
32+
{getValue<string>()}
33+
</span>
34+
),
35+
},
36+
{
37+
id: "channel_name",
38+
accessorKey: "channel_name",
39+
header: "Channel",
40+
cell: ({ getValue }) => (
41+
<span className="text-sm text-gray-500 dark:text-gray-400">#{getValue<string>()}</span>
42+
),
43+
},
44+
{
45+
id: "timestamp",
46+
accessorKey: "timestamp",
47+
header: ({ column }) => <SortableHeader column={column} label="Timestamp" />,
48+
cell: ({ getValue }) => new Date(getValue<string>()).toLocaleString(),
49+
sortingFn: "basic",
50+
},
51+
];
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"use client";
2+
3+
import {
4+
flexRender,
5+
getCoreRowModel,
6+
getSortedRowModel,
7+
useReactTable,
8+
type SortingState,
9+
} from "@tanstack/react-table";
10+
import { useState } from "react";
11+
import {
12+
Table,
13+
TableBody,
14+
TableCell,
15+
TableHead,
16+
TableHeader,
17+
TableRow,
18+
} from "@/components/ui/table";
19+
import { slackTagsColumns } from "./SlackTagsColumns";
20+
import type { SlackTag } from "@/types/coding-agent";
21+
22+
interface SlackTagsTableProps {
23+
tags: SlackTag[];
24+
}
25+
26+
export default function SlackTagsTable({ tags }: SlackTagsTableProps) {
27+
const [sorting, setSorting] = useState<SortingState>([
28+
{ id: "timestamp", desc: true },
29+
]);
30+
31+
const table = useReactTable({
32+
data: tags,
33+
columns: slackTagsColumns,
34+
state: { sorting },
35+
onSortingChange: setSorting,
36+
getCoreRowModel: getCoreRowModel(),
37+
getSortedRowModel: getSortedRowModel(),
38+
});
39+
40+
return (
41+
<div className="rounded-lg border">
42+
<Table>
43+
<TableHeader>
44+
{table.getHeaderGroups().map((headerGroup) => (
45+
<TableRow key={headerGroup.id}>
46+
{headerGroup.headers.map((header) => (
47+
<TableHead key={header.id}>
48+
{header.isPlaceholder
49+
? null
50+
: flexRender(header.column.columnDef.header, header.getContext())}
51+
</TableHead>
52+
))}
53+
</TableRow>
54+
))}
55+
</TableHeader>
56+
<TableBody>
57+
{table.getRowModel().rows.length ? (
58+
table.getRowModel().rows.map((row) => (
59+
<TableRow key={row.id}>
60+
{row.getVisibleCells().map((cell) => (
61+
<TableCell key={cell.id}>
62+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
63+
</TableCell>
64+
))}
65+
</TableRow>
66+
))
67+
) : (
68+
<TableRow>
69+
<TableCell colSpan={slackTagsColumns.length} className="h-24 text-center">
70+
No results.
71+
</TableCell>
72+
</TableRow>
73+
)}
74+
</TableBody>
75+
</Table>
76+
</div>
77+
);
78+
}

components/Home/AdminDashboard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default function AdminDashboard() {
1212
<NavButton href="/sandboxes" label="View Sandboxes" />
1313
<NavButton href="/sandboxes/orgs" label="View Org Commits" />
1414
<NavButton href="/privy" label="View Privy Logins" />
15+
<NavButton href="/coding" label="Coding Agent Tags" />
1516
</nav>
1617
</div>
1718
);

components/PrivyLogins/PrivyLoginsPage.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
55
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
66
import { usePrivyLogins } from "@/hooks/usePrivyLogins";
77
import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable";
8-
import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector";
8+
import PeriodSelector from "@/components/Admin/PeriodSelector";
99
import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats";
1010
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
1111
import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
12-
import PrivyLastSeenChart from "@/components/PrivyLogins/PrivyLastSeenChart";
13-
import type { PrivyLoginsPeriod } from "@/types/privy";
12+
import AdminLineChart from "@/components/Admin/AdminLineChart";
13+
import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate";
14+
import type { AdminPeriod } from "@/types/admin";
1415

1516
export default function PrivyLoginsPage() {
16-
const [period, setPeriod] = useState<PrivyLoginsPeriod>("all");
17+
const [period, setPeriod] = useState<AdminPeriod>("all");
1718
const { data, isLoading, error } = usePrivyLogins(period);
1819

1920
return (
@@ -32,7 +33,7 @@ export default function PrivyLoginsPage() {
3233
</div>
3334

3435
<div className="mb-6 flex items-center gap-4">
35-
<PrivyPeriodSelector period={period} onPeriodChange={setPeriod} />
36+
<PeriodSelector period={period} onPeriodChange={setPeriod} />
3637
{data && <PrivyLoginsStats data={data} />}
3738
</div>
3839

@@ -57,7 +58,7 @@ export default function PrivyLoginsPage() {
5758

5859
{!isLoading && !error && data && data.logins.length > 0 && (
5960
<>
60-
<PrivyLastSeenChart logins={data.logins} />
61+
<AdminLineChart title="Last Seen Activity" data={getLastSeenByDate(data.logins)} label="Last Seen" />
6162
<PrivyLoginsTable logins={data.logins} />
6263
</>
6364
)}

hooks/useSlackTags.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use client";
2+
3+
import { useQuery } from "@tanstack/react-query";
4+
import { usePrivy } from "@privy-io/react-auth";
5+
import { fetchSlackTags } from "@/lib/recoup/fetchSlackTags";
6+
import type { SlackTagsPeriod } from "@/types/coding-agent";
7+
8+
/**
9+
* Fetches Slack tagging analytics for the Recoup Coding Agent for the given period.
10+
* Authenticates with the Privy access token (admin Bearer auth).
11+
*/
12+
export function useSlackTags(period: SlackTagsPeriod) {
13+
const { ready, authenticated, getAccessToken } = usePrivy();
14+
15+
return useQuery({
16+
queryKey: ["admin", "coding-agent", "slack-tags", period],
17+
queryFn: async () => {
18+
const token = await getAccessToken();
19+
if (!token) throw new Error("Not authenticated");
20+
return fetchSlackTags(token, period);
21+
},
22+
enabled: ready && authenticated,
23+
});
24+
}

lib/coding-agent/getTagsByDate.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { SlackTag } from "@/types/coding-agent";
2+
3+
interface TagsByDateEntry {
4+
date: string;
5+
count: number;
6+
}
7+
8+
/**
9+
* Aggregates Slack tags by UTC date (YYYY-MM-DD) for charting.
10+
*
11+
* @param tags - Array of SlackTag objects
12+
* @returns Array of { date, count } sorted ascending by date
13+
*/
14+
export function getTagsByDate(tags: SlackTag[]): TagsByDateEntry[] {
15+
const counts: Record<string, number> = {};
16+
17+
for (const tag of tags) {
18+
const date = tag.timestamp.slice(0, 10); // "YYYY-MM-DD"
19+
counts[date] = (counts[date] ?? 0) + 1;
20+
}
21+
22+
return Object.entries(counts)
23+
.map(([date, count]) => ({ date, count }))
24+
.sort((a, b) => a.date.localeCompare(b.date));
25+
}

0 commit comments

Comments
 (0)