Skip to content
Merged
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
48 changes: 43 additions & 5 deletions src/app/admin/reviews/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import type { LucideIcon } from "lucide-react"
import { useReviewStore } from "@/stores/review-store"
import { useSchemaStore } from "@/stores/schema-store"
import { approveReview, dismissReview, listReviews, getReviewNodeTypeCounts, triggerMergeWorkflow } from "@/lib/graph-api"
import type { Review, ReviewStatus } from "@/lib/graph-api"
import type { DeciderCategory, Review, ReviewStatus } from "@/lib/graph-api"
import { ReviewRow, getApproveVerb } from "@/components/admin/review-row"
import { ReviewStatsPopover } from "@/components/admin/review-stats-popover"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { SelectCustom } from "@/components/ui/select-custom"
Expand Down Expand Up @@ -43,6 +44,18 @@ const SORT_OPTIONS = [
{ label: "Highest priority", value: "priority" },
]

// decided_at is null on pending rows, so this sort only makes sense once the
// view can contain decided reviews.
const DECIDED_SORT_OPTION = { label: "Recently decided", value: "decided_at" }

const DECIDER_OPTIONS: { label: string; value: DeciderCategory | "" }[] = [
{ label: "All reviewers", value: "" },
{ label: "Admin", value: "admin" },
{ label: "Workflow", value: "workflow" },
{ label: "System", value: "system" },
{ label: "Other", value: "other" },
]

const PAGE_SIZE = 50

function SkeletonRows() {
Expand Down Expand Up @@ -80,6 +93,13 @@ export default function ReviewsPage() {
const [statusFilter, setStatusFilter] = useState<ReviewStatus | "">("pending")
const [actionFilter, setActionFilter] = useState("")
const [sort, setSort] = useState("created_at")
const [deciderFilter, setDeciderFilter] = useState<DeciderCategory | "">("")

// The decided-only controls live on every tab but Pending.
const onDecidedView = statusFilter !== "pending"
const sortOptions = onDecidedView
? [...SORT_OPTIONS, DECIDED_SORT_OPTION]
: SORT_OPTIONS
const [searchQuery, setSearchQuery] = useState("")
const debouncedSearch = useDebounce(searchQuery, 300)

Expand Down Expand Up @@ -124,6 +144,7 @@ export default function ReviewsPage() {
limit: PAGE_SIZE,
search: debouncedSearch || undefined,
node_type: nodeTypeFilter || undefined,
decider: deciderFilter || undefined,
},
ctrl.signal
)
Expand Down Expand Up @@ -151,7 +172,7 @@ export default function ReviewsPage() {
if (!options?.silent) setLoading(false)
}
},
[statusFilter, actionFilter, sort, debouncedSearch, nodeTypeFilter]
[statusFilter, actionFilter, sort, debouncedSearch, nodeTypeFilter, deciderFilter]
)

useEffect(() => {
Expand All @@ -167,12 +188,13 @@ export default function ReviewsPage() {
status: statusFilter || undefined,
action_name: actionFilter || undefined,
search: debouncedSearch || undefined,
decider: deciderFilter || undefined,
})
setNodeTypeCounts(res.counts)
setTruncatedCounts(res.truncated)
} catch {}
}, 300)
}, [statusFilter, actionFilter, debouncedSearch])
}, [statusFilter, actionFilter, debouncedSearch, deciderFilter])

useEffect(() => {
fetchNodeTypeCounts()
Expand Down Expand Up @@ -401,7 +423,13 @@ export default function ReviewsPage() {
<button
key={tab.value || "all"}
type="button"
onClick={() => { setStatusFilter(tab.value); setNodeTypeFilter("") }}
onClick={() => {
setStatusFilter(tab.value)
setNodeTypeFilter("")
setDeciderFilter("")
// Pending rows have no decided_at — fall back to the default sort.
if (tab.value === "pending" && sort === "decided_at") setSort("created_at")
}}
className={cn(
"relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors",
active
Expand Down Expand Up @@ -483,13 +511,23 @@ export default function ReviewsPage() {
<span className="text-[11px] text-muted-foreground">
{total} {total === 1 ? "result" : "results"}
</span>
{onDecidedView && (
<SelectCustom
value={deciderFilter}
onChange={(v) => setDeciderFilter(v as DeciderCategory | "")}
options={DECIDER_OPTIONS}
compact
className="w-[130px]"
/>
)}
<SelectCustom
value={sort}
onChange={setSort}
options={SORT_OPTIONS}
options={sortOptions}
compact
className="w-[160px]"
/>
<ReviewStatsPopover actionName={actionFilter || undefined} />
</div>
</div>

Expand Down
201 changes: 201 additions & 0 deletions src/components/admin/review-stats-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"use client"

import { useCallback, useEffect, useRef, useState } from "react"
import { BarChart3 } from "lucide-react"
import { getReviewStats } from "@/lib/graph-api"
import type { DeciderCategory, ReviewStatsResponse } from "@/lib/graph-api"
import { AnchoredPopover } from "@/components/ui/anchored-popover"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

const WINDOW_DAYS = 7

const DECIDER_LABELS: Record<DeciderCategory, string> = {
admin: "Admin",
workflow: "Workflow",
system: "System",
other: "Other",
}

const DECIDER_ORDER: DeciderCategory[] = ["admin", "workflow", "system", "other"]

function weekdayLabel(isoDay: string, index: number, count: number) {
const d = new Date(`${isoDay}T00:00:00`)
if (index === count - 1) return "Today"
return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric" })
}

/**
* Trigger button + anchored panel showing the last week of review decisions:
* per-day stacked bars (approved/dismissed/failed) and a reviewer breakdown.
* Follows the page's action filter so the numbers match the list being viewed.
*/
export function ReviewStatsPopover({ actionName }: { actionName?: string }) {
const anchorRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [stats, setStats] = useState<ReviewStatsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)

const fetchStats = useCallback(async () => {
setLoading(true)
setError(false)
try {
const res = await getReviewStats({
days: WINDOW_DAYS,
action_name: actionName || undefined,
})
setStats(res)
} catch {
setError(true)
} finally {
setLoading(false)
}
}, [actionName])

// Fetch on every open so the numbers reflect decisions made since the last
// look; stale data stays visible while the refresh is in flight.
useEffect(() => {
if (open) fetchStats()
}, [open, fetchStats])

const maxDayTotal = stats
? Math.max(1, ...stats.days.map((d) => d.total))
: 1

return (
<div ref={anchorRef} className="flex">
<Button
size="sm"
variant="outline"
data-testid="review-stats-btn"
onClick={() => setOpen((v) => !v)}
title="Decision analytics — last 7 days"
className={cn(
"h-7 w-7 p-0",
open && "border-primary/70 bg-primary/10 text-primary"
)}
>
<BarChart3 className="h-3.5 w-3.5" />
</Button>

<AnchoredPopover
anchorRef={anchorRef}
open={open}
onClose={() => setOpen(false)}
matchWidth={false}
align="end"
maxHeight={420}
className="w-[300px] rounded-lg border border-border bg-popover p-3 shadow-lg"
>
<div className="overflow-y-auto" data-testid="review-stats-panel">
<div className="mb-2 flex items-baseline justify-between">
<span className="text-xs font-semibold">
Decisions — last {WINDOW_DAYS} days
</span>
{actionName && (
<span className="text-[10px] text-muted-foreground">{actionName}</span>
)}
</div>

{error ? (
<p className="py-4 text-center text-[11px] text-red-400">
Failed to load analytics
</p>
) : !stats && loading ? (
<div className="space-y-2 py-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-4 animate-pulse rounded bg-muted/30" />
))}
</div>
) : stats ? (
<div className={cn(loading && "opacity-60")}>
{/* Totals */}
<p className="mb-2 text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">
{stats.totals.total}
</span>{" "}
decided ·{" "}
<span className="text-emerald-400">{stats.totals.approved} approved</span> ·{" "}
<span className="text-rose-400">{stats.totals.dismissed} dismissed</span>
{stats.totals.failed > 0 && (
<>
{" "}· <span className="text-amber-400">{stats.totals.failed} failed</span>
</>
)}
</p>

{/* Per-day stacked bars */}
<div className="space-y-1.5">
{stats.days.map((d, i) => (
<div key={d.day} className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[10px] text-muted-foreground">
{weekdayLabel(d.day, i, stats.days.length)}
</span>
<div className="h-3 flex-1 overflow-hidden rounded-sm bg-muted/20">
{d.total > 0 && (
<div
className="flex h-full"
style={{ width: `${(d.total / maxDayTotal) * 100}%` }}
>
{d.approved > 0 && (
<div
className="h-full bg-emerald-500/70"
style={{ flexGrow: d.approved }}
/>
)}
{d.dismissed > 0 && (
<div
className="h-full bg-rose-500/70"
style={{ flexGrow: d.dismissed }}
/>
)}
{d.failed > 0 && (
<div
className="h-full bg-amber-500/70"
style={{ flexGrow: d.failed }}
/>
)}
</div>
)}
</div>
<span className="w-8 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">
{d.total}
</span>
</div>
))}
</div>

{/* Reviewer breakdown */}
<div className="mt-3 border-t border-border/60 pt-2">
<p className="mb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
By reviewer
</p>
<div className="flex flex-wrap gap-1">
{DECIDER_ORDER.filter((k) => stats.totals.deciders[k] > 0).map(
(k) => (
<span
key={k}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[10px] text-muted-foreground"
>
{DECIDER_LABELS[k]}
<span className="font-semibold text-foreground">
{stats.totals.deciders[k]}
</span>
</span>
)
)}
{stats.totals.total === 0 && (
<span className="text-[11px] text-muted-foreground">
No decisions in this window
</span>
)}
</div>
</div>
</div>
) : null}
</div>
</AnchoredPopover>
</div>
)
}
14 changes: 11 additions & 3 deletions src/components/ui/anchored-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ interface AnchoredPopoverProps {
// Upper bound on the surface height so a long list stays a tidy, scrollable
// popover instead of stretching to fill the viewport.
maxHeight?: number
// Which edge of the anchor the popover lines up with. "start" (default)
// pins the left edges together; "end" pins the right edges — use it for
// triggers near the right viewport edge so the surface grows leftwards.
align?: "start" | "end"
}

interface Position {
left: number
left?: number
right?: number
width?: number
top?: number
bottom?: number
Expand Down Expand Up @@ -50,6 +55,7 @@ export function AnchoredPopover({
matchWidth = true,
gap = 6,
maxHeight = DEFAULT_MAX_HEIGHT,
align = "start",
}: AnchoredPopoverProps) {
const popoverRef = React.useRef<HTMLDivElement>(null)
const [mounted, setMounted] = React.useState(false)
Expand All @@ -67,14 +73,15 @@ export function AnchoredPopover({
const placeBelow = spaceBelow >= PREFERRED_MIN || spaceBelow >= spaceAbove
const available = placeBelow ? spaceBelow : spaceAbove
setPos({
left: r.left,
left: align === "end" ? undefined : r.left,
right: align === "end" ? window.innerWidth - r.right : undefined,
width: matchWidth ? r.width : undefined,
top: placeBelow ? r.bottom + gap : undefined,
bottom: placeBelow ? undefined : window.innerHeight - r.top + gap,
// Cap to a tidy size, but never exceed the room actually available.
maxHeight: Math.min(maxHeight, Math.max(120, available)),
})
}, [anchorRef, gap, matchWidth, maxHeight])
}, [anchorRef, gap, matchWidth, maxHeight, align])

// Position on open and keep it pinned as the page scrolls/resizes. Capture
// scroll so we also catch scrolling inside ancestor containers (the modal).
Expand Down Expand Up @@ -113,6 +120,7 @@ export function AnchoredPopover({
style={{
position: "fixed",
left: pos.left,
right: pos.right,
top: pos.top,
bottom: pos.bottom,
width: pos.width,
Expand Down
Loading
Loading