mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
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:
co-authored by
Claude Fable 5
parent
02bd1d42e0
commit
a949729c07
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user