diff --git a/app/coding/page.tsx b/app/coding/page.tsx
new file mode 100644
index 0000000..c39b691
--- /dev/null
+++ b/app/coding/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import CodingAgentSlackTagsPage from "@/components/CodingAgentSlackTags/CodingAgentSlackTagsPage";
+
+export const metadata: Metadata = {
+ title: "Coding Agent Slack Tags — Recoup Admin",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/components/PrivyLogins/PrivyLastSeenChart.tsx b/components/Admin/AdminLineChart.tsx
similarity index 72%
rename from components/PrivyLogins/PrivyLastSeenChart.tsx
rename to components/Admin/AdminLineChart.tsx
index e501b1c..c5d1d5e 100644
--- a/components/PrivyLogins/PrivyLastSeenChart.tsx
+++ b/components/Admin/AdminLineChart.tsx
@@ -7,30 +7,26 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
-import type { PrivyUser } from "@/types/privy";
-import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate";
-const chartConfig = {
- count: {
- label: "Last Seen",
- color: "#345A5D",
- },
-} satisfies ChartConfig;
-
-interface PrivyLastSeenChartProps {
- logins: PrivyUser[];
+interface AdminLineChartProps {
+ title: string;
+ data: Array<{ date: string; count: number }>;
+ label?: string;
}
-export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps) {
- const data = getLastSeenByDate(logins);
-
+export default function AdminLineChart({ title, data, label = "Count" }: AdminLineChartProps) {
if (data.length === 0) return null;
+ const chartConfig = {
+ count: {
+ label,
+ color: "#345A5D",
+ },
+ } satisfies ChartConfig;
+
return (
-
- Last Seen Activity
-
+
{title}
@@ -50,7 +46,11 @@ export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps)
{
const d = new Date(String(value) + "T00:00:00");
- return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
+ return d.toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
}}
/>
}
diff --git a/components/PrivyLogins/PrivyPeriodSelector.tsx b/components/Admin/PeriodSelector.tsx
similarity index 68%
rename from components/PrivyLogins/PrivyPeriodSelector.tsx
rename to components/Admin/PeriodSelector.tsx
index 5295113..f42b2e1 100644
--- a/components/PrivyLogins/PrivyPeriodSelector.tsx
+++ b/components/Admin/PeriodSelector.tsx
@@ -1,18 +1,18 @@
-import type { PrivyLoginsPeriod } from "@/types/privy";
+import type { AdminPeriod } from "@/types/admin";
-const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [
+const PERIODS: { value: AdminPeriod; label: string }[] = [
{ value: "all", label: "All Time" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
];
-interface PrivyPeriodSelectorProps {
- period: PrivyLoginsPeriod;
- onPeriodChange: (period: PrivyLoginsPeriod) => void;
+interface PeriodSelectorProps {
+ period: AdminPeriod;
+ onPeriodChange: (period: AdminPeriod) => void;
}
-export default function PrivyPeriodSelector({ period, onPeriodChange }: PrivyPeriodSelectorProps) {
+export default function PeriodSelector({ period, onPeriodChange }: PeriodSelectorProps) {
return (
{PERIODS.map(({ value, label }) => (
diff --git a/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx
new file mode 100644
index 0000000..4b529b2
--- /dev/null
+++ b/components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState } from "react";
+import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
+import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
+import { useSlackTags } from "@/hooks/useSlackTags";
+import SlackTagsTable from "./SlackTagsTable";
+import AdminLineChart from "@/components/Admin/AdminLineChart";
+import { getTagsByDate } from "@/lib/coding-agent/getTagsByDate";
+import PeriodSelector from "@/components/Admin/PeriodSelector";
+import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
+import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
+import type { AdminPeriod } from "@/types/admin";
+
+export default function CodingAgentSlackTagsPage() {
+ const [period, setPeriod] = useState
("all");
+ const { data, isLoading, error } = useSlackTags(period);
+
+ return (
+
+
+
+
+
+ Coding Agent Slack Tags
+
+
+ Slack mentions of the Recoup Coding Agent, pulled directly from the Slack API.
+
+
+
+
+
+
+
+ {data && (
+
+ {data.total}{" "}
+ {data.total === 1 ? "tag" : "tags"} found
+
+ )}
+
+
+ {isLoading && (
+ <>
+
+
+ >
+ )}
+
+ {error && (
+
+ {error instanceof Error ? error.message : "Failed to load Slack tags"}
+
+ )}
+
+ {!isLoading && !error && data && data.tags.length === 0 && (
+
+ No tags found for this period.
+
+ )}
+
+ {!isLoading && !error && data && data.tags.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/CodingAgentSlackTags/SlackTagsColumns.tsx b/components/CodingAgentSlackTags/SlackTagsColumns.tsx
new file mode 100644
index 0000000..8dad5a3
--- /dev/null
+++ b/components/CodingAgentSlackTags/SlackTagsColumns.tsx
@@ -0,0 +1,51 @@
+import { type ColumnDef } from "@tanstack/react-table";
+import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
+import type { SlackTag } from "@/types/coding-agent";
+
+export const slackTagsColumns: ColumnDef[] = [
+ {
+ id: "user_name",
+ accessorKey: "user_name",
+ header: "Tagged By",
+ cell: ({ row }) => {
+ const tag = row.original;
+ return (
+
+ {tag.user_avatar && (
+

+ )}
+
{tag.user_name}
+
+ );
+ },
+ },
+ {
+ id: "prompt",
+ accessorKey: "prompt",
+ header: "Prompt",
+ cell: ({ getValue }) => (
+
+ {getValue()}
+
+ ),
+ },
+ {
+ id: "channel_name",
+ accessorKey: "channel_name",
+ header: "Channel",
+ cell: ({ getValue }) => (
+ #{getValue()}
+ ),
+ },
+ {
+ id: "timestamp",
+ accessorKey: "timestamp",
+ header: ({ column }) => ,
+ cell: ({ getValue }) => new Date(getValue()).toLocaleString(),
+ sortingFn: "basic",
+ },
+];
diff --git a/components/CodingAgentSlackTags/SlackTagsTable.tsx b/components/CodingAgentSlackTags/SlackTagsTable.tsx
new file mode 100644
index 0000000..49c8ce1
--- /dev/null
+++ b/components/CodingAgentSlackTags/SlackTagsTable.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+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 { slackTagsColumns } from "./SlackTagsColumns";
+import type { SlackTag } from "@/types/coding-agent";
+
+interface SlackTagsTableProps {
+ tags: SlackTag[];
+}
+
+export default function SlackTagsTable({ tags }: SlackTagsTableProps) {
+ const [sorting, setSorting] = useState([
+ { id: "timestamp", desc: true },
+ ]);
+
+ const table = useReactTable({
+ data: tags,
+ columns: slackTagsColumns,
+ state: { sorting },
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ });
+
+ return (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx
index 2fbc5ed..1fbc8a3 100644
--- a/components/Home/AdminDashboard.tsx
+++ b/components/Home/AdminDashboard.tsx
@@ -12,6 +12,7 @@ export default function AdminDashboard() {
+
);
diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx
index e1ee633..b4090ad 100644
--- a/components/PrivyLogins/PrivyLoginsPage.tsx
+++ b/components/PrivyLogins/PrivyLoginsPage.tsx
@@ -5,15 +5,16 @@ import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import { usePrivyLogins } from "@/hooks/usePrivyLogins";
import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable";
-import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector";
+import PeriodSelector from "@/components/Admin/PeriodSelector";
import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats";
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
-import PrivyLastSeenChart from "@/components/PrivyLogins/PrivyLastSeenChart";
-import type { PrivyLoginsPeriod } from "@/types/privy";
+import AdminLineChart from "@/components/Admin/AdminLineChart";
+import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate";
+import type { AdminPeriod } from "@/types/admin";
export default function PrivyLoginsPage() {
- const [period, setPeriod] = useState("all");
+ const [period, setPeriod] = useState("all");
const { data, isLoading, error } = usePrivyLogins(period);
return (
@@ -32,7 +33,7 @@ export default function PrivyLoginsPage() {
@@ -57,7 +58,7 @@ export default function PrivyLoginsPage() {
{!isLoading && !error && data && data.logins.length > 0 && (
<>
-
+
>
)}
diff --git a/hooks/useSlackTags.ts b/hooks/useSlackTags.ts
new file mode 100644
index 0000000..c7f37f9
--- /dev/null
+++ b/hooks/useSlackTags.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { usePrivy } from "@privy-io/react-auth";
+import { fetchSlackTags } from "@/lib/recoup/fetchSlackTags";
+import type { SlackTagsPeriod } from "@/types/coding-agent";
+
+/**
+ * Fetches Slack tagging analytics for the Recoup Coding Agent for the given period.
+ * Authenticates with the Privy access token (admin Bearer auth).
+ */
+export function useSlackTags(period: SlackTagsPeriod) {
+ const { ready, authenticated, getAccessToken } = usePrivy();
+
+ return useQuery({
+ queryKey: ["admin", "coding-agent", "slack-tags", period],
+ queryFn: async () => {
+ const token = await getAccessToken();
+ if (!token) throw new Error("Not authenticated");
+ return fetchSlackTags(token, period);
+ },
+ enabled: ready && authenticated,
+ });
+}
diff --git a/lib/coding-agent/getTagsByDate.ts b/lib/coding-agent/getTagsByDate.ts
new file mode 100644
index 0000000..2222be3
--- /dev/null
+++ b/lib/coding-agent/getTagsByDate.ts
@@ -0,0 +1,25 @@
+import type { SlackTag } from "@/types/coding-agent";
+
+interface TagsByDateEntry {
+ date: string;
+ count: number;
+}
+
+/**
+ * Aggregates Slack tags by UTC date (YYYY-MM-DD) for charting.
+ *
+ * @param tags - Array of SlackTag objects
+ * @returns Array of { date, count } sorted ascending by date
+ */
+export function getTagsByDate(tags: SlackTag[]): TagsByDateEntry[] {
+ const counts: Record = {};
+
+ for (const tag of tags) {
+ const date = tag.timestamp.slice(0, 10); // "YYYY-MM-DD"
+ counts[date] = (counts[date] ?? 0) + 1;
+ }
+
+ return Object.entries(counts)
+ .map(([date, count]) => ({ date, count }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
diff --git a/lib/recoup/fetchSlackTags.ts b/lib/recoup/fetchSlackTags.ts
new file mode 100644
index 0000000..12500e8
--- /dev/null
+++ b/lib/recoup/fetchSlackTags.ts
@@ -0,0 +1,29 @@
+import { API_BASE_URL } from "@/lib/consts";
+import type { SlackTagsPeriod, SlackTagsResponse } from "@/types/coding-agent";
+
+/**
+ * Fetches Slack tagging analytics for the Recoup Coding Agent from GET /api/admins/coding/slack.
+ * Authenticates using the caller's Privy access token (admin Bearer auth).
+ *
+ * @param accessToken - Privy access token from getAccessToken()
+ * @param period - Time period: "all", "daily", "weekly", or "monthly"
+ * @returns SlackTagsResponse with total count and list of tag events
+ */
+export async function fetchSlackTags(
+ accessToken: string,
+ period: SlackTagsPeriod,
+): Promise {
+ const url = new URL(`${API_BASE_URL}/api/admins/coding/slack`);
+ 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();
+}
diff --git a/types/admin.ts b/types/admin.ts
new file mode 100644
index 0000000..225a060
--- /dev/null
+++ b/types/admin.ts
@@ -0,0 +1 @@
+export type AdminPeriod = "all" | "daily" | "weekly" | "monthly";
diff --git a/types/coding-agent.ts b/types/coding-agent.ts
new file mode 100644
index 0000000..26e9ffd
--- /dev/null
+++ b/types/coding-agent.ts
@@ -0,0 +1,17 @@
+export interface SlackTag {
+ user_id: string;
+ user_name: string;
+ user_avatar: string | null;
+ prompt: string;
+ timestamp: string;
+ channel_id: string;
+ channel_name: string;
+}
+
+export type { AdminPeriod as SlackTagsPeriod } from "./admin";
+
+export interface SlackTagsResponse {
+ status: "success";
+ total: number;
+ tags: SlackTag[];
+}
diff --git a/types/privy.ts b/types/privy.ts
index 846b3c0..1b06dda 100644
--- a/types/privy.ts
+++ b/types/privy.ts
@@ -19,7 +19,7 @@ export type PrivyUser = {
is_guest: boolean;
};
-export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly";
+export type { AdminPeriod as PrivyLoginsPeriod } from "./admin";
export type PrivyLoginsResponse = {
status: "success" | "error";