diff --git a/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx index 9a6298a604..31e2c423f7 100644 --- a/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx +++ b/apps/web/src/app/(app)/wasteland/[wastelandId]/claims/ClaimsClient.tsx @@ -1,5 +1,545 @@ 'use client'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useWastelandTRPC } from '@/lib/wasteland/trpc'; +import type { WastelandOutputs } from '@/lib/wasteland/trpc'; +import { useSetWastelandPageHeader } from '../WastelandPageHeaderContext'; +import { useDrawerStack } from '@/components/wasteland/drawer/WastelandDrawerStack'; +import { Badge } from '@/components/ui/badge'; +import { + ArrowUpDown, + ExternalLink, + Hourglass, + MoreHorizontal, + RefreshCw, + Search, + ShieldAlert, + ScrollText, +} from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { AnimatePresence, motion } from 'motion/react'; +import { toast } from 'sonner'; +import { parseDoltDate } from '@/lib/wasteland/date'; +import { STATUS_DOT, PRIORITY_COLORS, TYPE_COLORS } from '@/lib/wasteland/status-colors'; +import Link from 'next/link'; + +type ClaimRow = WastelandOutputs['wasteland']['listClaims'][number]; +type ClaimItem = ClaimRow['item']; + +type SortMode = 'recent' | 'priority' | 'stale'; + +const PR_KIND_LABELS: Record = { + claim: 'claim PR', + done: 'done PR', + unclaim: 'unclaim PR', +}; + +const COLD_START_DELAY_MS = 3000; + +function useSlowOperationToast(isPending: boolean) { + const timerRef = useRef | null>(null); + const toastIdRef = useRef(null); + + useEffect(() => { + if (isPending) { + timerRef.current = setTimeout(() => { + toastIdRef.current = toast.loading('Starting wasteland container...'); + }, COLD_START_DELAY_MS); + } else { + if (timerRef.current) clearTimeout(timerRef.current); + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + } + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (toastIdRef.current !== null) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + }; + }, [isPending]); +} + export function ClaimsClient({ wastelandId }: { wastelandId: string }) { - return
Wasteland claims coming soon (wasteland: {wastelandId})
; + const trpc = useWastelandTRPC(); + const queryClient = useQueryClient(); + const { open: openDrawer } = useDrawerStack(); + + const [search, setSearch] = useState(''); + const [sortMode, setSortMode] = useState('recent'); + const [rigHandleFilter, setRigHandleFilter] = useState(null); + const [pendingPrOnly, setPendingPrOnly] = useState(false); + const [activeMenuItemId, setActiveMenuItemId] = useState(null); + + useEffect(() => { + if (!activeMenuItemId) return; + const handler = () => setActiveMenuItemId(null); + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [activeMenuItemId]); + + const claimsQuery = useQuery({ + ...trpc.wasteland.listClaims.queryOptions({ + wastelandId, + ...(rigHandleFilter ? { rigHandle: rigHandleFilter } : {}), + }), + refetchInterval: 30_000, + }); + + const credentialQuery = useQuery( + trpc.wasteland.getCredentialStatus.queryOptions({ wastelandId }) + ); + const isAdmin = credentialQuery.data?.is_upstream_admin ?? false; + + useSlowOperationToast(claimsQuery.isLoading && !claimsQuery.data); + + const claims = claimsQuery.data ?? []; + + const rigHandles = useMemo( + () => Array.from(new Set(claims.map(c => c.item.claimed_by).filter(Boolean))) as string[], + [claims] + ); + + const filteredClaims = useMemo(() => { + let result = claims; + + if (search.trim()) { + const q = search.toLowerCase(); + result = result.filter( + c => + c.item.title.toLowerCase().includes(q) || + c.item.id.toLowerCase().includes(q) || + (c.item.claimed_by ?? '').toLowerCase().includes(q) + ); + } + + if (pendingPrOnly) { + result = result.filter(c => c.pending_pr !== null); + } + + result = [...result].sort((a, b) => { + if (sortMode === 'priority') { + const priorityOrder: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + }; + const aP = priorityOrder[String(a.item.priority ?? 'medium')] ?? 2; + const bP = priorityOrder[String(b.item.priority ?? 'medium')] ?? 2; + return aP - bP; + } + if (sortMode === 'stale') { + const aTime = parseDoltDate(a.item.updated_at)?.getTime() ?? 0; + const bTime = parseDoltDate(b.item.updated_at)?.getTime() ?? 0; + return aTime - bTime; + } + const aTime = parseDoltDate(a.item.updated_at)?.getTime() ?? 0; + const bTime = parseDoltDate(b.item.updated_at)?.getTime() ?? 0; + return bTime - aTime; + }); + + return result; + }, [claims, search, sortMode, pendingPrOnly]); + + const stats = useMemo(() => { + const total = claims.length; + const pendingPr = claims.filter(c => c.pending_pr !== null).length; + const staleThreshold = Date.now() - 24 * 60 * 60 * 1000; + const stale = claims.filter(c => { + const updated = parseDoltDate(c.item.updated_at)?.getTime() ?? 0; + return updated > 0 && updated < staleThreshold; + }).length; + return { total, pendingPr, stale }; + }, [claims]); + + const handleRefresh = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: trpc.wasteland.listClaims.queryKey({ + wastelandId, + ...(rigHandleFilter ? { rigHandle: rigHandleFilter } : {}), + }), + }); + }, [queryClient, trpc, wastelandId, rigHandleFilter]); + + const handleOpenItem = useCallback( + (item: ClaimItem) => { + openDrawer({ + type: 'wanted-item', + wastelandId, + item, + actions: { + isAdmin, + onDone: () => {}, + onAccept: () => {}, + onReject: () => {}, + onCloseItem: () => {}, + onUnclaim: () => {}, + }, + }); + }, + [openDrawer, wastelandId, isAdmin] + ); + + const cycleSort = useCallback(() => { + setSortMode(prev => { + if (prev === 'recent') return 'priority'; + if (prev === 'priority') return 'stale'; + return 'recent'; + }); + }, []); + + const sortLabel: Record = { + recent: 'Recently claimed', + priority: 'Priority', + stale: 'Stale (oldest first)', + }; + + useSetWastelandPageHeader({ + title: 'Claims', + icon: , + count: claims.length, + actions: ( + <> + + {claims.length} active claim{claims.length !== 1 ? 's' : ''} + + + + ), + }); + + return ( +
+
+ {/* Filter bar */} +
+
+ + setSearch(e.target.value)} + className="w-48 bg-transparent text-xs text-white/80 outline-none placeholder:text-white/25" + /> +
+ + {/* Rig handle filter */} + + + {/* Pending PR only toggle */} + + + {/* Sort */} + +
+ + {/* Stats strip */} +
+ + + 0} /> +
+ + {/* Claims table */} +
+ {claimsQuery.isLoading && } + + {!claimsQuery.isLoading && claimsQuery.isError && ( +
+

Failed to load claims

+ +
+ )} + + {!claimsQuery.isLoading && !claimsQuery.isError && filteredClaims.length === 0 && ( +
+ +

+ {search || rigHandleFilter || pendingPrOnly + ? 'No claims match your filters.' + : 'No active claims right now'} +

+ {!search && !rigHandleFilter && !pendingPrOnly && ( + <> +

+ When a rig claims a wanted item, it'll appear here +

+ + Browse the wanted board + + + )} +
+ )} + + + {filteredClaims.map((claim, i) => ( + handleOpenItem(claim.item)} + activeMenuItemId={activeMenuItemId} + setActiveMenuItemId={setActiveMenuItemId} + delay={Math.min(i * 0.02, 0.3)} + /> + ))} + +
+
+
+ ); +} + +function ClaimRowComponent({ + claim, + wastelandId, + isAdmin, + onClick, + activeMenuItemId, + setActiveMenuItemId, + delay, +}: { + claim: ClaimRow; + wastelandId: string; + isAdmin: boolean; + onClick: () => void; + activeMenuItemId: string | null; + setActiveMenuItemId: (id: string | null) => void; + delay: number; +}) { + const { item, pending_pr: pendingPr } = claim; + const menuOpen = activeMenuItemId === item.id; + + return ( + { + if ((e.target as HTMLElement).closest('[data-claim-action]')) return; + onClick(); + }} + > + + claimed + + + + + {item.title.length > 60 ? `${item.title.slice(0, 60)}…` : item.title} + + + + {String(item.priority ?? 'medium')} + + + + {item.type ?? 'other'} + + + {item.claimed_by ? ( + e.stopPropagation()} + data-claim-action + > + {item.claimed_by} + + ) : ( + unclaimed + )} + + {(() => { + const ts = parseDoltDate(item.updated_at); + if (!ts) return ; + return ( + + {formatDistanceToNow(ts, { addSuffix: true })} + + ); + })()} + + {pendingPr && ( + + + {PR_KIND_LABELS[pendingPr.kind] ?? 'PR'} + + )} + + {/* Kebab menu */} +
+ + + {menuOpen && ( +
+ {pendingPr && ( + e.stopPropagation()} + > + + View on DoltHub + + )} + {isAdmin && ( + + )} + +
+ )} +
+
+ ); +} + +function StatsCard({ + label, + value, + highlight, +}: { + label: string; + value: number; + highlight?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + +function ClaimsTableSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); } diff --git a/apps/web/src/lib/wasteland/status-colors.ts b/apps/web/src/lib/wasteland/status-colors.ts new file mode 100644 index 0000000000..9c0376194b --- /dev/null +++ b/apps/web/src/lib/wasteland/status-colors.ts @@ -0,0 +1,31 @@ +export const STATUS_COLORS: Record = { + open: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', + claimed: 'bg-amber-500/10 text-amber-400 border-amber-500/20', + in_review: 'bg-violet-500/10 text-violet-400 border-violet-500/20', + completed: 'bg-sky-500/10 text-sky-400 border-sky-500/20', + done: 'bg-sky-500/10 text-sky-400 border-sky-500/20', + withdrawn: 'bg-white/[0.04] text-white/40 border-white/10', +}; + +export const STATUS_DOT: Record = { + open: 'bg-emerald-400', + claimed: 'bg-amber-400', + in_review: 'bg-violet-400', + completed: 'bg-sky-400', + done: 'bg-sky-400', + withdrawn: 'bg-white/20', +}; + +export const PRIORITY_COLORS: Record = { + low: 'text-white/55', + medium: 'text-sky-300', + high: 'text-amber-300', + critical: 'text-red-300', +}; + +export const TYPE_COLORS: Record = { + feature: 'bg-violet-500/10 text-violet-400 border-violet-500/20', + bug: 'bg-red-500/10 text-red-400 border-red-500/20', + docs: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + other: 'bg-white/[0.04] text-white/40 border-white/10', +}; diff --git a/apps/web/src/lib/wasteland/types/router.d.ts b/apps/web/src/lib/wasteland/types/router.d.ts index ff5e0736ea..ea86e1a364 100644 --- a/apps/web/src/lib/wasteland/types/router.d.ts +++ b/apps/web/src/lib/wasteland/types/router.d.ts @@ -338,6 +338,39 @@ export declare const wastelandRouter: import('@trpc/server').TRPCBuiltRouter< }; meta: object; }>; + listClaims: import('@trpc/server').TRPCQueryProcedure<{ + input: { + wastelandId: string; + rigHandle?: string | undefined; + }; + output: { + item: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }; + pending_pr: { + pull_id: string; + pr_url: string; + kind: 'claim' | 'done' | 'unclaim'; + } | null; + }[]; + meta: object; + }>; claimWantedItem: import('@trpc/server').TRPCMutationProcedure<{ input: { wastelandId: string; @@ -1093,6 +1126,39 @@ export declare const wrappedWastelandRouter: import('@trpc/server').TRPCBuiltRou }; meta: object; }>; + listClaims: import('@trpc/server').TRPCQueryProcedure<{ + input: { + wastelandId: string; + rigHandle?: string | undefined; + }; + output: { + item: { + id: string; + title: string; + description: string | null; + project: string | null; + type: string | null; + priority: string | number | null; + tags: string | null; + posted_by: string | null; + claimed_by: string | null; + status: string; + effort_level: string | null; + evidence_url: string | null; + sandbox_required: string | number | null; + sandbox_scope: string | null; + sandbox_min_tier: string | null; + created_at: string | null; + updated_at: string | null; + }; + pending_pr: { + pull_id: string; + pr_url: string; + kind: 'claim' | 'done' | 'unclaim'; + } | null; + }[]; + meta: object; + }>; claimWantedItem: import('@trpc/server').TRPCMutationProcedure<{ input: { wastelandId: string; diff --git a/services/wasteland/src/trpc/router.ts b/services/wasteland/src/trpc/router.ts index cd2bfc20d5..5c98288ba0 100644 --- a/services/wasteland/src/trpc/router.ts +++ b/services/wasteland/src/trpc/router.ts @@ -32,6 +32,7 @@ import { RpcUpstreamRigOutput, RpcRigDetailOutput, RpcRigActivityOutput, + RpcClaimRowOutput, WantedBoardRowOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -1025,6 +1026,95 @@ export const wastelandRouter = router({ } }), + // ── Claims page (claimed items with pending-PR enrichment) ───────── + + listClaims: procedure + .input( + z.object({ + wastelandId: z.string().uuid(), + rigHandle: z.string().optional(), + }) + ) + .output(z.array(RpcClaimRowOutput)) + .query(async ({ ctx, input }) => { + await resolveWastelandOwnership(ctx.env, ctx, input.wastelandId); + + let allItems: Array>; + try { + allItems = await wantedBoard.browseWantedBoard(ctx.env, input.wastelandId, ctx.userId); + } catch (err) { + if (err instanceof WantedBoardOpError && err.code === 'PRECONDITION_FAILED') { + return []; + } + return wantedBoardErrorToTRPC(err); + } + + let claimed = allItems.filter( + item => typeof item.status === 'string' && item.status === 'claimed' + ); + + if (input.rigHandle) { + claimed = claimed.filter( + item => typeof item.claimed_by === 'string' && item.claimed_by === input.rigHandle + ); + } + + const prByItemId = new Map< + string, + { pull_id: string; pr_url: string; kind: 'claim' | 'done' | 'unclaim' } + >(); + + try { + const { token, upstream } = await loadAdminContext(ctx.env, input.wastelandId, ctx.userId); + const openPulls = await doltApi.listPulls(upstream, token, { state: 'Open' }); + const details = await doltApi.mapWithLimit(openPulls, 6, p => + doltApi.getPull(upstream, token, p.pull_id).catch(() => null) + ); + + const claimedItemIds = new Set( + claimed.map(item => (typeof item.id === 'string' ? item.id : '')).filter(Boolean) + ); + + for (const detail of details) { + if (!detail) continue; + const parsed = doltApi.parseWlBranch(detail.from_branch_name); + if (!parsed) continue; + if (!claimedItemIds.has(parsed.itemId)) continue; + + const parsedCommit = inbox.parseCommitSubject(detail.title); + let kind: 'claim' | 'done' | 'unclaim' = 'claim'; + if (parsedCommit.kind === 'wl') { + if (parsedCommit.verb === 'done') { + kind = 'done'; + } else if (parsedCommit.verb === 'unclaim') { + kind = 'unclaim'; + } + } + + if (!prByItemId.has(parsed.itemId)) { + prByItemId.set(parsed.itemId, { + pull_id: detail.pull_id, + pr_url: doltApi.buildPullWebUrl(upstream, detail.pull_id), + kind, + }); + } + } + } catch { + // PR enrichment is best-effort + } + + return claimed.map(item => { + const itemId = typeof item.id === 'string' ? item.id : ''; + const parsed = WantedBoardRowOutput.safeParse(item); + return { + item: parsed.success + ? parsed.data + : WantedBoardRowOutput.parse({ id: itemId, title: '', status: 'claimed' }), + pending_pr: prByItemId.get(itemId) ?? null, + }; + }); + }), + // ── Wanted Board Mutations ──────────────────────────────────────── claimWantedItem: procedure diff --git a/services/wasteland/src/trpc/schemas.ts b/services/wasteland/src/trpc/schemas.ts index fb11d1cfa5..a2fbaea766 100644 --- a/services/wasteland/src/trpc/schemas.ts +++ b/services/wasteland/src/trpc/schemas.ts @@ -308,3 +308,19 @@ export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); export const RpcCompletionOutput = rpcSafe(CompletionOutput); export const RpcStampOutput = rpcSafe(StampOutput); export const RpcRigActivityOutput = rpcSafe(RigActivityOutput); + +// ── Claims page: pending PR enrichment ──────────────────────────────── + +export const PendingPrOutput = z.object({ + pull_id: z.string(), + pr_url: z.string(), + kind: z.enum(['claim', 'done', 'unclaim']), +}); + +export const ClaimRowOutput = z.object({ + item: WantedBoardRowOutput, + pending_pr: PendingPrOutput.nullable(), +}); + +export const RpcPendingPrOutput = rpcSafe(PendingPrOutput); +export const RpcClaimRowOutput = rpcSafe(ClaimRowOutput);