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
57 changes: 55 additions & 2 deletions components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink";
import { useSlackTags } from "@/hooks/useSlackTags";
import { useSlackTagOptions } from "@/hooks/useSlackTagOptions";
import { useCodingPrStatus } from "@/hooks/useCodingPrStatus";
import SlackTagsTable from "./SlackTagsTable";
import AdminLineChart from "@/components/Admin/AdminLineChart";
Expand All @@ -15,12 +16,23 @@

export default function CodingAgentSlackTagsPage() {
const [period, setPeriod] = useState<AdminPeriod>("all");
const { data, isLoading, error } = useSlackTags(period);
const [selectedTag, setSelectedTag] = useState<string | undefined>(undefined);

const { data: tagOptions } = useSlackTagOptions();
const { data, isLoading, error } = useSlackTags(period, selectedTag);
const { data: mergedPrUrls } = useCodingPrStatus(data?.tags);

const tagsByDate = data ? getTagsByDate(data.tags, mergedPrUrls) : [];
const totalMergedPrs = mergedPrUrls?.size ?? 0;

function handleTagClick(tagId: string) {
setSelectedTag((prev) => (prev === tagId ? undefined : tagId));
}

function handleClearTag() {
setSelectedTag(undefined);
}

return (
<main className="mx-auto max-w-6xl px-4 py-10">
<div className="mb-6 flex items-start justify-between">
Expand All @@ -36,6 +48,47 @@
<ApiDocsLink path="admins/coding-agent-slack-tags" />
</div>

{tagOptions && tagOptions.tags.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Filter by:</span>
{tagOptions.tags.map((tag) => {
const isActive = selectedTag === tag.id;
return (
<button
key={tag.id}
onClick={() => handleTagClick(tag.id)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-colors ${
isActive
? "text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
}`}
style={isActive ? { backgroundColor: "#345A5D" } : undefined}
>
{tag.avatar && (
<img

Check warning on line 68 in components/CodingAgentSlackTags/CodingAgentSlackTagsPage.tsx

View workflow job for this annotation

GitHub Actions / ESLint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={tag.avatar}
alt={tag.name}
className="h-4 w-4 rounded-full"
/>
)}
{tag.name}
{isActive && (
<span className="ml-0.5 text-xs opacity-75">×</span>
)}
</button>
);
})}
{selectedTag && (
<button
onClick={handleClearTag}
className="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 underline"
>
Clear filter
</button>
)}
</div>
)}

<div className="mb-6 flex items-center gap-4">
<PeriodSelector period={period} onPeriodChange={setPeriod} />
{data && (
Expand Down Expand Up @@ -81,7 +134,7 @@

{!isLoading && !error && data && data.tags.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No tags found for this period.
No tags found for this period{selectedTag ? " and filter" : ""}.
</div>
)}

Expand Down
23 changes: 23 additions & 0 deletions hooks/useSlackTagOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { fetchSlackTagOptions } from "@/lib/recoup/fetchSlackTagOptions";

/**
* Fetches the distinct Slack user tags (filter options) for the coding agent page.
* Authenticates with the Privy access token (admin Bearer auth).
*/
export function useSlackTagOptions() {
const { ready, authenticated, getAccessToken } = usePrivy();

return useQuery({
queryKey: ["admin", "coding-agent", "slack-tag-options"],
queryFn: async () => {
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchSlackTagOptions(token);
},
enabled: ready && authenticated,
});
}
7 changes: 4 additions & 3 deletions hooks/useSlackTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ import type { SlackTagsPeriod } from "@/types/coding-agent";

/**
* Fetches Slack tagging analytics for the Recoup Coding Agent for the given period.
* Optionally filters by a specific Slack user ID (tag).
* Authenticates with the Privy access token (admin Bearer auth).
*/
export function useSlackTags(period: SlackTagsPeriod) {
export function useSlackTags(period: SlackTagsPeriod, tag?: string) {
const { ready, authenticated, getAccessToken } = usePrivy();

return useQuery({
queryKey: ["admin", "coding-agent", "slack-tags", period],
queryKey: ["admin", "coding-agent", "slack-tags", period, tag],
queryFn: async () => {
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchSlackTags(token, period);
return fetchSlackTags(token, period, tag);
},
enabled: ready && authenticated,
});
Expand Down
24 changes: 24 additions & 0 deletions lib/recoup/fetchSlackTagOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { API_BASE_URL } from "@/lib/consts";
import type { SlackTagOptionsResponse } from "@/types/coding-agent";

/**
* Fetches the distinct set of Slack users who have tagged the Recoup Coding Agent.
* Used to populate filter chips in the admin coding page.
*
* @param accessToken - Privy access token from getAccessToken()
* @returns SlackTagOptionsResponse with unique tag options
*/
export async function fetchSlackTagOptions(accessToken: string): Promise<SlackTagOptionsResponse> {
const url = `${API_BASE_URL}/api/admins/coding-agent/slack-tags`;

const res = await fetch(url, {
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();
}
3 changes: 3 additions & 0 deletions lib/recoup/fetchSlackTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import type { SlackTagsPeriod, SlackTagsResponse } from "@/types/coding-agent";
*
* @param accessToken - Privy access token from getAccessToken()
* @param period - Time period: "all", "daily", "weekly", or "monthly"
* @param tag - Optional Slack user ID to filter results
* @returns SlackTagsResponse with total count and list of tag events
*/
export async function fetchSlackTags(
accessToken: string,
period: SlackTagsPeriod,
tag?: string,
): Promise<SlackTagsResponse> {
const url = new URL(`${API_BASE_URL}/api/admins/coding/slack`);
url.searchParams.set("period", period);
if (tag) url.searchParams.set("tag", tag);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
Expand Down
12 changes: 12 additions & 0 deletions types/coding-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ export interface CodingPrStatusResponse {
status: "success";
pull_requests: CodingPrStatus[];
}

export interface SlackTagOption {
id: string;
name: string;
avatar: string | null;
}

export interface SlackTagOptionsResponse {
status: "success";
total: number;
tags: SlackTagOption[];
}
Loading