fix(dashboard): paginate recent activity feed with a size picker

With real data the feed showed 20 rows by default and filled most of
the viewport. Switched to a 5-row default with an inline size picker
(5 / 10 / 20 / 50) so users choose how much history is visible.

- Dashboard page bumps the fetch from 20 → 50 so every size option is
  already in memory. Switching sizes is a pure client-side slice, no
  round trip.
- ActivityFeed owns the pageSize state, renders "Showing N of M"
  plus a button group in the footer. The smallest option is always
  enabled; larger options unlock only once we have enough rows to
  meaningfully reveal more (e.g. "10" enables once loaded > 5).
- Existing "View all" header link left alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 22:35:42 +03:00
parent 3e02098579
commit 96debd193b
No known key found for this signature in database
2 changed files with 90 additions and 38 deletions

View File

@ -84,7 +84,10 @@ export default function DashboardPage() {
.catch((err) => console.error('[dashboard] response time failed:', err)) .catch((err) => console.error('[dashboard] response time failed:', err))
.finally(() => setResponseTimeLoading(false)) .finally(() => setResponseTimeLoading(false))
void loadActivity(db, 20) // Fetch up to 50 so the biggest page-size option in the feed
// (50 rows) is already in memory — switching sizes then becomes
// a pure client-side slice with no extra round trip.
void loadActivity(db, 50)
.then((a) => setActivity(a)) .then((a) => setActivity(a))
.catch((err) => console.error('[dashboard] activity failed:', err)) .catch((err) => console.error('[dashboard] activity failed:', err))
.finally(() => setActivityLoading(false)) .finally(() => setActivityLoading(false))

View File

@ -1,6 +1,7 @@
"use client" "use client"
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { import {
MessageSquare, MessageSquare,
UserPlus, UserPlus,
@ -20,6 +21,9 @@ interface ActivityFeedProps {
loading: boolean loading: boolean
} }
const PAGE_SIZES = [5, 10, 20, 50] as const
type PageSize = (typeof PAGE_SIZES)[number]
interface KindTheme { interface KindTheme {
icon: ComponentType<{ className?: string }> icon: ComponentType<{ className?: string }>
/** Tailwind classes for the round icon badge + label color. */ /** Tailwind classes for the round icon badge + label color. */
@ -35,6 +39,20 @@ const KIND_THEME: Record<ActivityKind, KindTheme> = {
} }
export function ActivityFeed({ items, loading }: ActivityFeedProps) { export function ActivityFeed({ items, loading }: ActivityFeedProps) {
// Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history.
const [pageSize, setPageSize] = useState<PageSize>(5)
const totalLoaded = items?.length ?? 0
const visible = items?.slice(0, pageSize) ?? []
// A size option is "useful" if picking it would reveal rows the
// smaller option doesn't already show. With PAGE_SIZES=[5,10,20,50]:
// "10" is useful only once we've loaded ≥6 items, "20" once ≥11, etc.
// The smallest option is always enabled.
const isSizeUseful = (size: PageSize, i: number) =>
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
return ( return (
<section className="rounded-xl border border-slate-800 bg-slate-900"> <section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4"> <header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
@ -62,44 +80,75 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
/> />
</div> </div>
) : ( ) : (
<ul className="divide-y divide-slate-800"> <>
{items.map((it, i) => { <ul className="divide-y divide-slate-800">
const theme = KIND_THEME[it.kind] {visible.map((it, i) => {
const Icon = theme.icon const theme = KIND_THEME[it.kind]
// Alternating row background for light scanability — dark-theme const Icon = theme.icon
// translation of the spec's white / #f9fafb stripes. // Alternating row background for scanability — dark-theme
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40' // translation of the spec's white / #f9fafb stripes.
const row = ( const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40'
<div className="flex items-center gap-3 px-5 py-2.5"> const row = (
<span <div className="flex items-center gap-3 px-5 py-2.5">
className={cn( <span
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full', className={cn(
theme.badge, 'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full',
theme.badge,
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums">
{relativeTime(it.at)}
</span>
</div>
)
return (
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}>
{it.href ? (
<Link href={it.href} className="block">
{row}
</Link>
) : (
row
)} )}
> </li>
<Icon className="h-3.5 w-3.5" /> )
</span> })}
<span className="min-w-0 flex-1 truncate text-sm text-slate-200"> </ul>
{it.text} <footer className="flex items-center justify-between border-t border-slate-800 px-5 py-3 text-xs">
</span> <span className="text-slate-500 tabular-nums">
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums"> Showing {visible.length} of {totalLoaded}
{relativeTime(it.at)} {totalLoaded === 50 ? '+' : ''}
</span> </span>
</div> <div className="flex items-center gap-1">
) <span className="mr-1 text-slate-500">Show</span>
return ( {PAGE_SIZES.map((size, i) => {
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}> const disabled = !isSizeUseful(size, i)
{it.href ? ( return (
<Link href={it.href} className="block"> <button
{row} key={size}
</Link> type="button"
) : ( onClick={() => setPageSize(size)}
row disabled={disabled}
)} className={cn(
</li> 'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
) pageSize === size
})} ? 'bg-slate-700 text-white'
</ul> : 'text-slate-400 hover:bg-slate-800 hover:text-white',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-slate-400',
)}
>
{size}
</button>
)
})}
</div>
</footer>
</>
)} )}
</section> </section>
) )