refactor: match frustration tables to dashboard pattern

- Remove column headers for cleaner look
- Show secondary info (avg, sessions, last seen) on hover
- Add orange percentage badge that slides in on hover
- Add empty row padding for consistent card height
This commit is contained in:
Usman Baig
2026-03-12 18:27:20 +01:00
parent 13f6f53868
commit 5050422a60
2 changed files with 46 additions and 43 deletions

View File

@@ -166,6 +166,7 @@ export default function BehaviorPage() {
description="Elements users clicked repeatedly in frustration" description="Elements users clicked repeatedly in frustration"
items={rageClicks.items} items={rageClicks.items}
total={rageClicks.total} total={rageClicks.total}
totalSignals={summary?.rage_clicks ?? 0}
showAvgClicks showAvgClicks
loading={loading} loading={loading}
fetchAll={fetchAllRage} fetchAll={fetchAllRage}
@@ -175,6 +176,7 @@ export default function BehaviorPage() {
description="Elements users clicked that produced no response" description="Elements users clicked that produced no response"
items={deadClicks.items} items={deadClicks.items}
total={deadClicks.total} total={deadClicks.total}
totalSignals={summary?.dead_clicks ?? 0}
loading={loading} loading={loading}
fetchAll={fetchAllDead} fetchAll={fetchAllDead}
/> />

View File

@@ -8,11 +8,14 @@ import type { FrustrationElement } from '@/lib/api/stats'
import { formatRelativeTime } from '@/lib/utils/formatDate' import { formatRelativeTime } from '@/lib/utils/formatDate'
import { ListSkeleton } from '@/components/skeletons' import { ListSkeleton } from '@/components/skeletons'
const DISPLAY_LIMIT = 7
interface FrustrationTableProps { interface FrustrationTableProps {
title: string title: string
description: string description: string
items: FrustrationElement[] items: FrustrationElement[]
total: number total: number
totalSignals: number
showAvgClicks?: boolean showAvgClicks?: boolean
loading: boolean loading: boolean
fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }> fetchAll?: () => Promise<{ items: FrustrationElement[]; total: number }>
@@ -21,7 +24,7 @@ interface FrustrationTableProps {
function SkeletonRows() { function SkeletonRows() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: DISPLAY_LIMIT }).map((_, i) => (
<div key={i} className="animate-pulse flex items-center justify-between h-9 px-2"> <div key={i} className="animate-pulse flex items-center justify-between h-9 px-2">
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
<div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" /> <div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" />
@@ -51,7 +54,7 @@ function SelectorCell({ selector }: { selector: string }) {
className="flex items-center gap-1 min-w-0 group/copy cursor-pointer" className="flex items-center gap-1 min-w-0 group/copy cursor-pointer"
title={selector} title={selector}
> >
<span className="text-sm font-mono text-neutral-900 dark:text-white truncate max-w-[180px]"> <span className="text-sm font-mono text-neutral-900 dark:text-white truncate">
{selector} {selector}
</span> </span>
<span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0"> <span className="opacity-0 group-hover/copy:opacity-100 transition-opacity shrink-0">
@@ -65,41 +68,44 @@ function SelectorCell({ selector }: { selector: string }) {
) )
} }
const GRID_WITH_AVG = 'grid grid-cols-[1fr_60px_50px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2'
const GRID_NO_AVG = 'grid grid-cols-[1fr_60px_64px_64px_40px] items-center gap-2 h-9 px-2 -mx-2'
function Row({ function Row({
item, item,
showAvgClicks, showAvgClicks,
totalSignals,
}: { }: {
item: FrustrationElement item: FrustrationElement
showAvgClicks?: boolean showAvgClicks?: boolean
totalSignals: number
}) { }) {
const pct = totalSignals > 0 ? `${Math.round((item.count / totalSignals) * 100)}%` : ''
return ( return (
<div className={`${showAvgClicks ? GRID_WITH_AVG : GRID_NO_AVG} group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg transition-colors`}> <div className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex items-center gap-2 min-w-0"> <div className="flex-1 min-w-0">
<SelectorCell selector={item.selector} /> <div className="flex items-center gap-2">
<span <SelectorCell selector={item.selector} />
className="text-xs text-neutral-400 dark:text-neutral-500 truncate" <span
title={item.page_path} className="text-xs text-neutral-400 dark:text-neutral-500 truncate shrink-0"
> title={item.page_path}
{item.page_path} >
{item.page_path}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
{/* Secondary info: visible on hover */}
<span className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{showAvgClicks && item.avg_click_count != null ? `avg ${item.avg_click_count.toFixed(1)} · ` : ''}
{item.sessions} {item.sessions === 1 ? 'sess' : 'sess'} · {formatRelativeTime(item.last_seen)}
</span>
{/* Percentage badge: slides in on hover */}
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 tabular-nums">
{pct}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums">
{formatNumber(item.count)}
</span> </span>
</div> </div>
{showAvgClicks && (
<span className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums text-right">
{item.avg_click_count != null ? item.avg_click_count.toFixed(1) : ''}
</span>
)}
<span className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums text-right">
{item.sessions}
</span>
<span className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums text-right" title={item.last_seen}>
{formatRelativeTime(item.last_seen)}
</span>
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 tabular-nums text-right">
{formatNumber(item.count)}
</span>
</div> </div>
) )
} }
@@ -109,6 +115,7 @@ export default function FrustrationTable({
description, description,
items, items,
total, total,
totalSignals,
showAvgClicks, showAvgClicks,
loading, loading,
fetchAll, fetchAll,
@@ -118,6 +125,7 @@ export default function FrustrationTable({
const [isLoadingFull, setIsLoadingFull] = useState(false) const [isLoadingFull, setIsLoadingFull] = useState(false)
const hasData = items.length > 0 const hasData = items.length > 0
const showViewAll = hasData && total > items.length const showViewAll = hasData && total > items.length
const emptySlots = Math.max(0, DISPLAY_LIMIT - items.length)
useEffect(() => { useEffect(() => {
if (isModalOpen && fetchAll) { if (isModalOpen && fetchAll) {
@@ -165,21 +173,14 @@ export default function FrustrationTable({
{loading ? ( {loading ? (
<SkeletonRows /> <SkeletonRows />
) : hasData ? ( ) : hasData ? (
<div> <>
{/* Column headers */} {items.map((item, i) => (
<div className={`${showAvgClicks ? GRID_WITH_AVG : GRID_NO_AVG} mb-2 text-xs font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider !h-auto`}> <Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
<span>Selector / Page</span> ))}
{showAvgClicks && <span className="text-right">Avg</span>} {Array.from({ length: emptySlots }).map((_, i) => (
<span className="text-right">Sessions</span> <div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
<span className="text-right">Last Seen</span> ))}
<span className="text-right">Count</span> </>
</div>
<div className="space-y-0.5">
{items.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} />
))}
</div>
</div>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4"> <div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-4">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
@@ -210,7 +211,7 @@ export default function FrustrationTable({
) : fullData.length > 0 ? ( ) : fullData.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{fullData.map((item, i) => ( {fullData.map((item, i) => (
<Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} /> <Row key={`${item.selector}-${item.page_path}-${i}`} item={item} showAvgClicks={showAvgClicks} totalSignals={totalSignals} />
))} ))}
</div> </div>
) : ( ) : (