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} + )} + {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() {
- + {data && }
@@ -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";