feat: rebuild dashboard home around real usage and activity data

The home page was four all-time counters that could not answer either
question a user actually opens the dashboard for: how much quota is left, and
did my recent sends work.

Removed fabricated data:
- Every stat card rendered a green TrendingUp arrow whenever a value existed.
  Nothing computed a trend. The stats endpoint returns running totals with no
  time window, so there was nothing to compare against.
- "Since last year" on the sent and received counts. getStatsForUser sums
  device counters with no date filter, so these are all-time totals.
- "Active Devices" with a "Connected now" caption, over a number that counts
  every device including disabled ones. Enabled count is now derived from the
  device list we already fetch.
- "Active keys" over a count that included revoked keys; now uses the active
  API key list.

Added, all from fields the API already returns:
- Usage cards leading the page: today and this month against their limits,
  with progress bars, an amber near-limit state past 80% and an upgrade link.
  Unlimited plans (-1) show usage with no meter, since there is no ceiling to
  measure against.
- Recent activity: the last 5 messages for the connected device. Messages are
  only exposed per device, so the card names the device rather than implying
  account-wide coverage.
- All-time totals compacted from four large cards into one honest strip.

lib/usage.ts extracts the daily/monthly derivation that subscription-info
already did inline, so the dashboard and billing page cannot disagree about
remaining quota. Unit tested including the -1 sentinel, custom limit
overrides, and an over-100 percentage (possible when a limit is lowered
mid-period).

No backend changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-07-18 20:32:59 +03:00
co-authored by Claude Fable 5
parent 02bd1d42e0
commit a949729c07
8 changed files with 631 additions and 48 deletions
@@ -1,66 +1,94 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart3, Smartphone, Key, MessageSquare, TrendingUp } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { BarChart3, Smartphone, Key, MessageSquare } from 'lucide-react'
import GetStartedCard from './get-started'
import { useGatewayStats } from '@/lib/api'
import UsageSummary from './usage-summary'
import { useApiKeys, useDevices, useGatewayStats } from '@/lib/api'
import { Skeleton } from '@/components/ui/skeleton'
// import GetStartedCard from "@/components/get-started-card";
export const StatCard = ({ title, value, icon: Icon, description }) => {
// Compact all-time totals. Deliberately no trend indicators: the stats
// endpoint returns running totals with no time window, so there is nothing to
// compare against and any arrow would be invented.
function Stat({
label,
value,
caption,
icon: Icon,
}: {
label: string
value: string | number | undefined
caption: string
icon: typeof MessageSquare
}) {
return (
<Card className="overflow-hidden transition-all hover:shadow-md">
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{title}</CardTitle>
<div className="rounded-full bg-primary/10 p-2">
<Icon className='h-4 w-4 text-primary' />
<div className='flex items-center gap-3 px-4 py-3'>
<div className='rounded-full bg-primary/10 p-2'>
<Icon className='h-4 w-4 text-primary' />
</div>
<div className='min-w-0'>
<div className='text-lg font-bold leading-tight'>
{value !== undefined ? value : <Skeleton className='h-5 w-12' />}
</div>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>
{value !== undefined ? value : <Skeleton className='h-6 w-16' />}
</div>
<p className='text-xs text-muted-foreground mt-1 flex items-center'>
{description}
{value !== undefined && <TrendingUp className="ml-1 h-3 w-3 text-green-500" />}
<p className='truncate text-xs text-muted-foreground'>
{label}
{caption && <span className='ml-1 opacity-70'>{caption}</span>}
</p>
</div>
</div>
)
}
export function Totals() {
const { data: stats } = useGatewayStats()
const { data: devices } = useDevices()
const { data: apiKeys } = useApiKeys('active')
// The stats endpoint counts every device, enabled or not, so "enabled" has
// to be derived from the device list we already fetch.
const enabledDevices = devices?.filter((d) => d.enabled).length
const totalDevices = devices?.length ?? stats?.totalDeviceCount
return (
<Card className='overflow-hidden'>
<CardContent className='grid grid-cols-1 divide-y divide-border p-0 sm:grid-cols-2 lg:grid-cols-4'>
<Stat
label='SMS sent'
caption='all time'
value={stats?.totalSentSMSCount?.toLocaleString()}
icon={MessageSquare}
/>
<Stat
label='SMS received'
caption='all time'
value={stats?.totalReceivedSMSCount?.toLocaleString()}
icon={BarChart3}
/>
<Stat
label='Devices'
caption={
enabledDevices !== undefined ? `${enabledDevices} enabled` : ''
}
value={totalDevices}
icon={Smartphone}
/>
<Stat
label='API keys'
caption='active'
value={apiKeys?.length ?? stats?.totalApiKeyCount}
icon={Key}
/>
</CardContent>
</Card>
)
}
export default function Overview() {
const { data: stats } = useGatewayStats()
return (
<div className='space-y-6'>
<GetStartedCard />
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<StatCard
title='Total SMS Sent'
value={stats?.totalSentSMSCount?.toLocaleString()}
icon={MessageSquare}
description='Since last year'
/>
<StatCard
title='Active Devices'
value={stats?.totalDeviceCount}
icon={Smartphone}
description='Connected now'
/>
<StatCard
title='API Keys'
value={stats?.totalApiKeyCount}
icon={Key}
description='Active keys'
/>
<StatCard
title='SMS Received'
value={stats?.totalReceivedSMSCount?.toLocaleString()}
icon={BarChart3}
description='Since last year'
/>
</div>
<UsageSummary />
<Totals />
</div>
)
}
@@ -0,0 +1,115 @@
'use client'
import Link from 'next/link'
import { ArrowRight, MessageSquare } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import EmptyState from '@/components/shared/empty-state'
import { useDeviceMessages, useDevices } from '@/lib/api'
import { formatDeviceName } from '@/lib/utils'
import { getStatusBadge } from './message-history/utils'
import type { SmsMessage } from './message-history/types'
import { cn } from '@/lib/utils'
const RECENT_LIMIT = 5
// Messages are only exposed per device (/gateway/devices/:id/messages, there
// is no cross-device endpoint), so this shows the first enabled device and
// says which one, rather than implying it covers the whole account.
export default function RecentActivity() {
const { data: devices, isPending: devicesPending } = useDevices()
const device = devices?.find((d) => d.enabled) ?? devices?.[0]
const { data: messagesResponse, isPending: messagesPending } =
useDeviceMessages(
device?._id ?? '',
{ limit: RECENT_LIMIT },
{ enabled: Boolean(device?._id) }
)
const messages = (messagesResponse?.data ?? []) as SmsMessage[]
const isLoading = devicesPending || (Boolean(device) && messagesPending)
return (
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
<div className='min-w-0'>
<CardTitle className='text-base'>Recent activity</CardTitle>
{device && (
<p className='mt-0.5 truncate text-xs text-muted-foreground'>
{formatDeviceName(device)}
</p>
)}
</div>
<Link
href='/dashboard/messaging/history'
className='inline-flex shrink-0 items-center gap-1 text-sm font-medium text-primary hover:underline'
>
View all
<ArrowRight className='h-3.5 w-3.5' />
</Link>
</CardHeader>
<CardContent className='pt-0'>
{isLoading ? (
<div className='space-y-3'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex items-center gap-3'>
<Skeleton className='h-8 w-8 rounded-full' />
<div className='flex-1 space-y-1.5'>
<Skeleton className='h-3.5 w-32' />
<Skeleton className='h-3 w-full max-w-56' />
</div>
</div>
))}
</div>
) : !device ? (
<EmptyState
icon={MessageSquare}
title='No device connected yet'
hint='Register a device to start sending and receiving messages'
/>
) : messages.length === 0 ? (
<EmptyState
icon={MessageSquare}
title='No messages yet'
hint='Messages you send or receive will show up here'
/>
) : (
<ul className='divide-y divide-border'>
{messages.slice(0, RECENT_LIMIT).map((message) => {
const badge = getStatusBadge(message.status)
const target =
message.recipient ?? message.recipients?.[0] ?? message.sender
return (
<li
key={message._id}
className='flex items-start gap-3 py-2.5 first:pt-0 last:pb-0'
>
<div className='min-w-0 flex-1'>
<p className='truncate text-sm font-medium'>
{target ?? 'Unknown recipient'}
</p>
<p className='truncate text-xs text-muted-foreground'>
{message.message}
</p>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium',
badge.color
)}
>
{badge.icon}
{badge.label}
</span>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { http, HttpResponse } from 'msw'
import { renderWithProviders, screen, waitFor } from '@/test/render'
import { server } from '@/test/msw/server'
import { API_BASE_URL, mockSubscription } from '@/test/fixtures'
import UsageSummary from './usage-summary'
// The dashboard must only show numbers the backend actually returns. These
// guard the two ways that can go wrong: inventing a meter for an unlimited
// plan, and failing to warn when a real limit is nearly spent.
const subscriptionResponding = (subscription: unknown) =>
server.use(
http.get(`${API_BASE_URL}/billing/current-subscription`, () =>
HttpResponse.json(subscription)
)
)
describe('UsageSummary', () => {
it('shows usage against the limit for a metered plan', async () => {
renderWithProviders(<UsageSummary />)
await waitFor(() => expect(screen.getByText('320')).toBeInTheDocument())
// 320 of 5000 sent today.
expect(screen.getByText('/ 5,000')).toBeInTheDocument()
expect(screen.getByText('4,680 remaining')).toBeInTheDocument()
expect(
screen.getByRole('progressbar', { name: /today usage/i })
).toBeInTheDocument()
})
it('renders no progress bar for an unlimited plan', async () => {
subscriptionResponding({
...mockSubscription,
plan: { ...mockSubscription.plan, dailyLimit: -1, monthlyLimit: -1 },
usage: {
...mockSubscription.usage,
dailyLimit: -1,
monthlyLimit: -1,
},
})
renderWithProviders(<UsageSummary />)
await waitFor(() =>
expect(screen.getAllByText(/Unlimited on your plan/)).toHaveLength(2)
)
// A meter would be meaningless with no ceiling to measure against.
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
it('warns and offers an upgrade when close to the limit', async () => {
subscriptionResponding({
...mockSubscription,
usage: {
...mockSubscription.usage,
processedSmsToday: 4500,
dailyRemaining: 500,
dailyUsagePercentage: 90,
},
})
renderWithProviders(<UsageSummary />)
await waitFor(() =>
expect(screen.getByText('500 remaining')).toBeInTheDocument()
)
expect(screen.getByRole('link', { name: /upgrade/i })).toHaveAttribute(
'href',
'/dashboard/account/billing'
)
})
it('says the limit is reached rather than showing a remaining count', async () => {
subscriptionResponding({
...mockSubscription,
usage: {
...mockSubscription.usage,
processedSmsToday: 5000,
dailyRemaining: 0,
dailyUsagePercentage: 100,
},
})
renderWithProviders(<UsageSummary />)
await waitFor(() =>
expect(screen.getByText('Limit reached')).toBeInTheDocument()
)
})
})
@@ -0,0 +1,145 @@
'use client'
import Link from 'next/link'
import { ArrowRight, CalendarDays, Clock, Infinity as InfinityIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useSubscription } from '@/lib/api'
import { deriveUsage, type UsageWindow } from '@/lib/usage'
import { cn } from '@/lib/utils'
// The question a dashboard should answer first is "how much of my quota is
// left", which the all-time counters could never answer. Every value here
// comes from the subscription response; nothing is estimated.
function UsageCard({
title,
window: usageWindow,
icon: Icon,
isLoading,
}: {
title: string
window: UsageWindow
icon: typeof Clock
isLoading: boolean
}) {
if (isLoading) {
return (
<Card>
<CardContent className='space-y-3 p-5'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-7 w-32' />
<Skeleton className='h-1.5 w-full' />
</CardContent>
</Card>
)
}
const { used, limit, remaining, percentage, unlimited, nearLimit, atLimit } =
usageWindow
return (
<Card>
<CardContent className='space-y-3 p-5'>
<div className='flex items-center justify-between'>
<p className='text-sm font-medium text-muted-foreground'>{title}</p>
<Icon className='h-4 w-4 text-muted-foreground' />
</div>
{unlimited ? (
<>
<div className='flex items-baseline gap-2'>
<span className='text-2xl font-bold'>
{used.toLocaleString()}
</span>
<span className='text-sm text-muted-foreground'>sent</span>
</div>
<p className='flex items-center gap-1.5 text-xs text-muted-foreground'>
<InfinityIcon className='h-3.5 w-3.5' />
Unlimited on your plan
</p>
</>
) : (
<>
<div className='flex items-baseline gap-1.5'>
<span className='text-2xl font-bold'>
{used.toLocaleString()}
</span>
<span className='text-sm text-muted-foreground'>
/ {limit?.toLocaleString() ?? '-'}
</span>
</div>
<div
className='h-1.5 w-full overflow-hidden rounded-full bg-muted'
role='progressbar'
aria-valuenow={percentage}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${title} usage`}
>
<div
className={cn(
'h-full rounded-full transition-[width] duration-300',
atLimit
? 'bg-destructive'
: nearLimit
? 'bg-amber-500'
: 'bg-primary'
)}
style={{ width: `${percentage}%` }}
/>
</div>
<div className='flex items-center justify-between gap-2'>
<p
className={cn(
'text-xs',
atLimit
? 'font-medium text-destructive'
: nearLimit
? 'font-medium text-amber-600 dark:text-amber-500'
: 'text-muted-foreground'
)}
>
{atLimit
? 'Limit reached'
: `${remaining.toLocaleString()} remaining`}
</p>
{(nearLimit || atLimit) && (
<Link
href='/dashboard/account/billing'
className='inline-flex items-center gap-0.5 text-xs font-medium text-primary hover:underline'
>
Upgrade
<ArrowRight className='h-3 w-3' />
</Link>
)}
</div>
</>
)}
</CardContent>
</Card>
)
}
export default function UsageSummary() {
const { data: subscription, isPending } = useSubscription()
const { daily, monthly } = deriveUsage(subscription)
return (
<div className='grid gap-4 sm:grid-cols-2'>
<UsageCard
title='Today'
window={daily}
icon={Clock}
isLoading={isPending}
/>
<UsageCard
title='This month'
window={monthly}
icon={CalendarDays}
isLoading={isPending}
/>
</div>
)
}
+4
View File
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
import DeviceList from './(components)/device-list'
import Overview from './(components)/overview'
import ApiKeys from './(components)/api-keys'
import RecentActivity from './(components)/recent-activity'
import GenerateApiKey, {
type GenerateApiKeyHandle,
} from './(components)/generate-api-key'
@@ -102,8 +103,11 @@ export default function DashboardPage() {
</div>
<div className='space-y-6'>
{/* Onboarding, quota usage, then all-time totals. */}
<Overview />
<RecentActivity />
<div className='grid gap-6 md:grid-cols-2'>
<DeviceList />
<ApiKeys />
+50 -2
View File
@@ -33,10 +33,12 @@ test.describe('dashboard (mocked API, no real backend)', () => {
).toBeVisible()
// Quick actions row.
await expect(page.getByRole('link', { name: 'Send SMS' })).toBeVisible()
// Total SMS Sent stat from the mocked gateway stats fixture (12,840).
// Total SMS sent stat from the mocked gateway stats fixture (12,840).
await expect(page.getByText('12,840')).toBeVisible()
// Onboarding card shows its progress bar (all 6 steps done in fixtures).
await expect(page.getByRole('progressbar')).toBeVisible()
await expect(
page.getByRole('progressbar', { name: 'Setup progress' })
).toBeVisible()
await expect(page.getByText('6 of 6')).toBeVisible()
// Webhooks summary row keeps a mobile path to /dashboard/webhooks
// (fixtures have 1 active webhook).
@@ -44,4 +46,50 @@ test.describe('dashboard (mocked API, no real backend)', () => {
page.getByRole('link', { name: /active webhook/ })
).toBeVisible()
})
test('leads with real quota usage, not invented trends', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
// Daily and monthly windows straight from the subscription fixture:
// 320 of 5,000 today, 18,450 of 100,000 this month.
await expect(
page.getByRole('progressbar', { name: 'Today usage' })
).toBeVisible()
await expect(
page.getByRole('progressbar', { name: 'This month usage' })
).toBeVisible()
await expect(page.getByText('/ 5,000')).toBeVisible()
await expect(page.getByText('4,680 remaining')).toBeVisible()
// The old page decorated every stat with a green trend arrow and captioned
// all-time totals "Since last year". Nothing computed either.
await expect(page.getByText('Since last year')).toHaveCount(0)
await expect(page.getByText('Connected now')).toHaveCount(0)
})
test('shows recent messages for the connected device', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/dashboard')
const activity = page
.locator('div')
.filter({ hasText: /^Recent activity/ })
.first()
await expect(activity).toBeVisible()
// Mocked message body and its recipient.
await expect(page.getByText('Hello from textbee')).toBeVisible()
await expect(
page.getByRole('link', { name: /view all/i })
).toHaveAttribute('href', '/dashboard/messaging/history')
})
})
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { deriveUsage, UNLIMITED } from './usage'
describe('deriveUsage', () => {
it('reads the daily and monthly windows from usage', () => {
const { daily, monthly } = deriveUsage({
plan: { dailyLimit: 500, monthlyLimit: 5000 },
usage: {
dailyLimit: 500,
monthlyLimit: 5000,
processedSmsToday: 47,
processedSmsLastMonth: 1204,
dailyRemaining: 453,
monthlyRemaining: 3796,
dailyUsagePercentage: 9,
monthlyUsagePercentage: 24,
},
})
expect(daily.used).toBe(47)
expect(daily.limit).toBe(500)
expect(daily.remaining).toBe(453)
expect(daily.percentage).toBe(9)
expect(daily.unlimited).toBe(false)
expect(monthly.used).toBe(1204)
expect(monthly.percentage).toBe(24)
})
it('treats -1 as unlimited and suppresses the percentage', () => {
const { daily } = deriveUsage({
plan: { dailyLimit: UNLIMITED },
usage: { dailyLimit: UNLIMITED, processedSmsToday: 900 },
})
expect(daily.unlimited).toBe(true)
expect(daily.percentage).toBe(0)
expect(daily.nearLimit).toBe(false)
expect(daily.atLimit).toBe(false)
// Usage is still reported, there is just nothing to measure it against.
expect(daily.used).toBe(900)
})
it('prefers usage limits over plan limits (custom overrides)', () => {
const { daily } = deriveUsage({
plan: { dailyLimit: 50 },
usage: { dailyLimit: 5000, processedSmsToday: 100 },
})
expect(daily.limit).toBe(5000)
})
it('flags the near-limit threshold', () => {
const { daily } = deriveUsage({
usage: { dailyLimit: 100, processedSmsToday: 85, dailyUsagePercentage: 85 },
})
expect(daily.nearLimit).toBe(true)
expect(daily.atLimit).toBe(false)
})
it('flags being at the limit and clamps an over-100 percentage', () => {
// Happens when a limit is lowered mid-period.
const { daily } = deriveUsage({
usage: {
dailyLimit: 100,
processedSmsToday: 150,
dailyUsagePercentage: 150,
},
})
expect(daily.percentage).toBe(100)
expect(daily.atLimit).toBe(true)
expect(daily.nearLimit).toBe(false)
})
it('is safe on an undefined subscription', () => {
const { daily, monthly } = deriveUsage(undefined)
expect(daily.used).toBe(0)
expect(daily.limit).toBeUndefined()
expect(daily.unlimited).toBe(false)
expect(monthly.used).toBe(0)
})
})
+67
View File
@@ -0,0 +1,67 @@
import type { Subscription } from '@/lib/api/types'
// The backend uses -1 to mean "no limit".
export const UNLIMITED = -1
export type UsageWindow = {
used: number
limit: number | undefined
remaining: number
// 0-100, clamped. Meaningless when unlimited.
percentage: number
unlimited: boolean
// True once the user is close enough to the limit to warrant a nudge.
nearLimit: boolean
atLimit: boolean
}
export const NEAR_LIMIT_PERCENT = 80
function buildWindow(
used: number | undefined,
limit: number | undefined,
remaining: number | undefined,
percentage: number | undefined
): UsageWindow {
const unlimited = limit === UNLIMITED
// The backend reports percentage directly, but it is only meaningful for a
// real limit and can exceed 100 when a limit was lowered mid-period.
const pct = unlimited ? 0 : Math.min(100, Math.max(0, percentage ?? 0))
return {
used: used ?? 0,
limit,
remaining: remaining ?? 0,
percentage: pct,
unlimited,
nearLimit: !unlimited && pct >= NEAR_LIMIT_PERCENT && pct < 100,
atLimit: !unlimited && pct >= 100,
}
}
/**
* Derive the daily and monthly send windows from a subscription.
*
* Shared by the dashboard usage cards and the billing page so the two can
* never disagree about how much quota is left. `usage` values win over `plan`
* values because they already account for per-account custom overrides.
*/
export function deriveUsage(subscription: Subscription | undefined) {
const plan = subscription?.plan
const usage = subscription?.usage
return {
daily: buildWindow(
usage?.processedSmsToday,
usage?.dailyLimit ?? plan?.dailyLimit,
usage?.dailyRemaining,
usage?.dailyUsagePercentage
),
monthly: buildWindow(
usage?.processedSmsLastMonth,
usage?.monthlyLimit ?? plan?.monthlyLimit,
usage?.monthlyRemaining,
usage?.monthlyUsagePercentage
),
}
}