diff --git a/web/app/(app)/dashboard/(components)/overview.tsx b/web/app/(app)/dashboard/(components)/overview.tsx index 755cfb8..b33678a 100644 --- a/web/app/(app)/dashboard/(components)/overview.tsx +++ b/web/app/(app)/dashboard/(components)/overview.tsx @@ -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 ( - - - {title} -
- +
+
+ +
+
+
+ {value !== undefined ? value : }
- - -
- {value !== undefined ? value : } -
-

- {description} - {value !== undefined && } +

+ {label} + {caption && {caption}}

+
+
+ ) +} + +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 ( + + + + + + ) } export default function Overview() { - const { data: stats } = useGatewayStats() - return (
-
- - - - -
+ +
) } diff --git a/web/app/(app)/dashboard/(components)/recent-activity.tsx b/web/app/(app)/dashboard/(components)/recent-activity.tsx new file mode 100644 index 0000000..4b63c29 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/recent-activity.tsx @@ -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 ( + + +
+ Recent activity + {device && ( +

+ {formatDeviceName(device)} +

+ )} +
+ + View all + + +
+ + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : !device ? ( + + ) : messages.length === 0 ? ( + + ) : ( +
    + {messages.slice(0, RECENT_LIMIT).map((message) => { + const badge = getStatusBadge(message.status) + const target = + message.recipient ?? message.recipients?.[0] ?? message.sender + + return ( +
  • +
    +

    + {target ?? 'Unknown recipient'} +

    +

    + {message.message} +

    +
    + + {badge.icon} + {badge.label} + +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/web/app/(app)/dashboard/(components)/usage-summary.test.tsx b/web/app/(app)/dashboard/(components)/usage-summary.test.tsx new file mode 100644 index 0000000..9a15905 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/usage-summary.test.tsx @@ -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() + + 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() + + 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() + + 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() + + await waitFor(() => + expect(screen.getByText('Limit reached')).toBeInTheDocument() + ) + }) +}) diff --git a/web/app/(app)/dashboard/(components)/usage-summary.tsx b/web/app/(app)/dashboard/(components)/usage-summary.tsx new file mode 100644 index 0000000..e5fa649 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/usage-summary.tsx @@ -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 ( + + + + + + + + ) + } + + const { used, limit, remaining, percentage, unlimited, nearLimit, atLimit } = + usageWindow + + return ( + + +
+

{title}

+ +
+ + {unlimited ? ( + <> +
+ + {used.toLocaleString()} + + sent +
+

+ + Unlimited on your plan +

+ + ) : ( + <> +
+ + {used.toLocaleString()} + + + / {limit?.toLocaleString() ?? '-'} + +
+ +
+
+
+ +
+

+ {atLimit + ? 'Limit reached' + : `${remaining.toLocaleString()} remaining`} +

+ {(nearLimit || atLimit) && ( + + Upgrade + + + )} +
+ + )} + + + ) +} + +export default function UsageSummary() { + const { data: subscription, isPending } = useSubscription() + const { daily, monthly } = deriveUsage(subscription) + + return ( +
+ + +
+ ) +} diff --git a/web/app/(app)/dashboard/page.tsx b/web/app/(app)/dashboard/page.tsx index c7d23c1..0d9aa2a 100644 --- a/web/app/(app)/dashboard/page.tsx +++ b/web/app/(app)/dashboard/page.tsx @@ -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() {
+ {/* Onboarding, quota usage, then all-time totals. */} + +
diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts index 473a996..4589d19 100644 --- a/web/e2e/dashboard.spec.ts +++ b/web/e2e/dashboard.spec.ts @@ -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') + }) }) diff --git a/web/lib/usage.test.ts b/web/lib/usage.test.ts new file mode 100644 index 0000000..d2e38d3 --- /dev/null +++ b/web/lib/usage.test.ts @@ -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) + }) +}) diff --git a/web/lib/usage.ts b/web/lib/usage.ts new file mode 100644 index 0000000..d85d89d --- /dev/null +++ b/web/lib/usage.ts @@ -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 + ), + } +}