From 8c579d060ab8bdb7a00f78599e4eb653a940f235 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 16:47:55 -0400 Subject: [PATCH] fix(ui): loading skeletons for dashboard/spoons/threads --- apps/next/src/app/(app)/dashboard/page.tsx | 124 +++++++++++------- apps/next/src/app/(app)/spoons/page.tsx | 40 ++++-- apps/next/src/app/(app)/threads/page.tsx | 15 ++- apps/next/src/components/ui/list-skeleton.tsx | 23 ++++ .../component/dashboard-loading.test.tsx | 72 ++++++++++ 5 files changed, 213 insertions(+), 61 deletions(-) create mode 100644 apps/next/src/components/ui/list-skeleton.tsx create mode 100644 apps/next/tests/component/dashboard-loading.test.tsx diff --git a/apps/next/src/app/(app)/dashboard/page.tsx b/apps/next/src/app/(app)/dashboard/page.tsx index 1edaf22..73baa58 100644 --- a/apps/next/src/app/(app)/dashboard/page.tsx +++ b/apps/next/src/app/(app)/dashboard/page.tsx @@ -3,27 +3,38 @@ import Link from 'next/link'; import { MetricCard } from '@/components/dashboard/metric-card'; import { SpoonCard } from '@/components/spoons/spoon-card'; +import { ListSkeleton } from '@/components/ui/list-skeleton'; import { MaintenanceQueue } from '@/components/threads/maintenance-queue'; import { useQuery } from 'convex/react'; import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react'; import { api } from '@spoon/backend/convex/_generated/api.js'; -import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from '@spoon/ui'; const DashboardPage = () => { - const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; - const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; - const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? []; - const activeSpoons = spoons.filter( + const spoons = useQuery(api.spoons.listMineWithState, {}); + const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }); + const threads = useQuery(api.threads.listMine, { limit: 25 }); + const spoonList = spoons ?? []; + const threadList = threads ?? []; + const metricsLoading = spoons === undefined || threads === undefined; + const activeSpoons = spoonList.filter( (spoon) => spoon.status === 'active', ).length; - const behind = spoons.filter( + const behind = spoonList.filter( (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0, ).length; - const diverged = spoons.filter( + const diverged = spoonList.filter( (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0, ).length; - const openPullRequests = spoons.reduce( + const openPullRequests = spoonList.reduce( (total, spoon) => total + spoon.effectiveUpstreamAheadBy, 0, ); @@ -43,49 +54,68 @@ const DashboardPage = () => { -
- - - - !['resolved', 'ignored', 'failed', 'cancelled'].includes( - thread.status, - ), - ).length - } - note='Across all Spoons' - icon={MessageSquare} - /> - -
+ {metricsLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} + Loading +
+ ) : ( +
+ + + + !['resolved', 'ignored', 'failed', 'cancelled'].includes( + thread.status, + ), + ).length + } + note='Across all Spoons' + icon={MessageSquare} + /> + +
+ )}

Maintenance queue

- + {threads === undefined ? ( + + ) : ( + + )}

Recent Spoons

- {spoons.length ? ( + {spoons === undefined ? ( + + ) : spoons.length ? ( spoons .slice(0, 3) .map((spoon) => ) @@ -108,7 +138,9 @@ const DashboardPage = () => { Upstream checks - {syncRuns.length ? ( + {syncRuns === undefined ? ( + + ) : syncRuns.length ? (
{syncRuns.map((run) => (
{ Recent threads - {threads.length ? ( + {threads === undefined ? ( + + ) : threads.length ? (
{threads.slice(0, 5).map((thread) => (
const SpoonsPage = () => { const router = useRouter(); - const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; - const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? []; - const active = spoons.filter((spoon) => spoon.status === 'active').length; - const needsReview = threads.filter( + const spoons = useQuery(api.spoons.listMineWithState, {}); + const threads = useQuery(api.threads.listMine, { limit: 100 }); + const spoonList = spoons ?? []; + const threadList = threads ?? []; + const spoonsLoading = spoons === undefined; + const metricsLoading = spoons === undefined || threads === undefined; + const active = spoonList.filter((spoon) => spoon.status === 'active').length; + const needsReview = threadList.filter( (thread) => thread.spoonId && !['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status), ).length; - const upstreamWaiting = spoons.reduce( + const upstreamWaiting = spoonList.reduce( (total, spoon) => total + spoon.effectiveUpstreamAheadBy, 0, ); @@ -65,7 +71,11 @@ const SpoonsPage = () => {

Managed

-

{spoons.length}

+ {spoonsLoading ? ( + + ) : ( +

{spoonList.length}

+ )}
@@ -74,7 +84,11 @@ const SpoonsPage = () => {

Active

-

{active}

+ {spoonsLoading ? ( + + ) : ( +

{active}

+ )}
@@ -83,14 +97,20 @@ const SpoonsPage = () => {

Open threads

-

{needsReview}

+ {metricsLoading ? ( + + ) : ( +

{needsReview}

+ )}
- {spoons.length ? ( + {spoons === undefined ? ( + + ) : spoons.length ? ( @@ -201,7 +221,7 @@ const SpoonsPage = () => { )} - {spoons.length ? ( + {spoonList.length ? (

Actionable upstream commits waiting across all Spoons:{' '} {upstreamWaiting} diff --git a/apps/next/src/app/(app)/threads/page.tsx b/apps/next/src/app/(app)/threads/page.tsx index a11127c..cacc1b5 100644 --- a/apps/next/src/app/(app)/threads/page.tsx +++ b/apps/next/src/app/(app)/threads/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { DeleteThreadButton } from '@/components/threads/delete-thread-button'; +import { ListSkeleton } from '@/components/ui/list-skeleton'; import { useMutation, useQuery } from 'convex/react'; import { MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; @@ -48,8 +49,7 @@ const ThreadsPage = () => { const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const defaultProfile = profiles.find((profile) => profile.isDefault); - const threads = - useQuery(api.threads.listMine, { + const threads = useQuery(api.threads.listMine, { source: source as | 'all' | 'user_request' @@ -69,9 +69,10 @@ const ThreadsPage = () => { | 'ignored' | 'failed' | 'cancelled', - limit: 100, - }) ?? []; - const visibleThreads = threads.filter((thread) => { + limit: 100, + }); + const threadsLoading = threads === undefined; + const visibleThreads = (threads ?? []).filter((thread) => { if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false; if (priorityFilter !== 'all' && thread.priority !== priorityFilter) { return false; @@ -312,7 +313,9 @@ const ThreadsPage = () => {

- {visibleThreads.length ? ( + {threadsLoading ? ( + + ) : visibleThreads.length ? ( visibleThreads.map((thread) => ( ( +
+ {Array.from({ length: rows }).map((_, index) => ( + + ))} + Loading +
+); diff --git a/apps/next/tests/component/dashboard-loading.test.tsx b/apps/next/tests/component/dashboard-loading.test.tsx new file mode 100644 index 0000000..690e0c3 --- /dev/null +++ b/apps/next/tests/component/dashboard-loading.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import DashboardPage from '../../src/app/(app)/dashboard/page'; + +const { mockUseQuery } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useConvexAuth: () => ({ isAuthenticated: false }), + useMutation: vi.fn(), + useQuery: mockUseQuery, +})); + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +describe('dashboard loading vs empty state', () => { + afterEach(() => { + cleanup(); + mockUseQuery.mockReset(); + }); + + it('shows skeletons while queries are loading (undefined)', () => { + mockUseQuery.mockReturnValue(undefined); + + render(); + + expect(screen.getAllByTestId('list-skeleton').length).toBeGreaterThan(0); + expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument(); + }); + + it('shows empty states once loaded with no data ([])', () => { + mockUseQuery.mockReturnValue([]); + + render(); + + expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument(); + expect(screen.getByText('No Spoons yet')).toBeInTheDocument(); + }); + + it('renders data once loaded with items', () => { + mockUseQuery.mockImplementation((_query, args) => { + // spoons list has an empty args object ({}); recent syncRuns/threads pass limits. + if (args && 'limit' in args) return []; + return [ + { + _id: 'spoon-1', + name: 'useSend', + upstreamOwner: 'acme', + upstreamRepo: 'app', + status: 'active', + syncCadence: 'daily', + effectiveUpstreamAheadBy: 0, + rawUpstreamAheadBy: 0, + forkAheadBy: 0, + ignoredUpstreamCount: 0, + }, + ]; + }); + + render(); + + expect(screen.getByText('useSend')).toBeInTheDocument(); + expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument(); + expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument(); + }); +});