From 07397decd8e1e5725262271d69ec7c2ccf778516 Mon Sep 17 00:00:00 2001 From: nobledeveloper01 Date: Tue, 16 Jun 2026 18:46:15 +0100 Subject: [PATCH 1/2] feat(frontend): add loading skeletons and button loading states (closes #1) --- .env.example | 2 +- .gitignore | 11 +++- frontend/app/accounts/page.tsx | 28 +++++++-- frontend/app/dashboard/page.tsx | 42 ++++++++++--- frontend/app/login/page.tsx | 4 +- frontend/app/recommendations/page.tsx | 65 +++++++++++++------ frontend/app/register/page.tsx | 4 +- frontend/components/ui/Skeleton.tsx | 89 +++++++++++++++++++++++++++ frontend/components/ui/Spinner.tsx | 25 ++++++++ frontend/lib/utils.ts | 9 +++ frontend/tailwind.config.js | 10 +++ 11 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 frontend/components/ui/Skeleton.tsx create mode 100644 frontend/components/ui/Spinner.tsx diff --git a/.env.example b/.env.example index 78748e2..4ec494b 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ # Example environment variables DATABASE_URL=postgres://user:password@localhost:5432/cloud_costs?sslmode=disable JWT_SECRET=your_jwt_secret_here -NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file +NEXT_PUBLIC_API_URL=http://localhost:8080 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7e14a8c..c580ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,13 @@ Thumbs.db # Build outputs dist/ -build/ \ No newline at end of file +build/ +*.tsbuildinfo + +# Claude / AI tooling (never commit) +.claude/ +CLAUDE.md +.qodo/ + +# Local-only compose overrides +docker-compose.override.yml \ No newline at end of file diff --git a/frontend/app/accounts/page.tsx b/frontend/app/accounts/page.tsx index 087b40e..20b5bd7 100644 --- a/frontend/app/accounts/page.tsx +++ b/frontend/app/accounts/page.tsx @@ -3,11 +3,15 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import api from '@/lib/api' +import { withMinDuration } from '@/lib/utils' +import { AccountRowSkeleton } from '@/components/ui/Skeleton' +import { Spinner } from '@/components/ui/Spinner' export default function AccountsPage() { const router = useRouter() const [activeTab, setActiveTab] = useState('aws') const [loading, setLoading] = useState(false) + const [accountsLoading, setAccountsLoading] = useState(true) const [error, setError] = useState('') const [success, setSuccess] = useState('') const [existingAccounts, setExistingAccounts] = useState([]) @@ -50,10 +54,12 @@ export default function AccountsPage() { const fetchAccounts = async () => { try { - const response = await api.get('/accounts') + const response = await withMinDuration(api.get('/accounts'), 1000) setExistingAccounts(response.data) } catch (error) { console.error('Failed to fetch accounts:', error) + } finally { + setAccountsLoading(false) } } @@ -179,8 +185,17 @@ export default function AccountsPage() {
{/* Existing Accounts Section */} - {existingAccounts.length > 0 && ( + {accountsLoading ? (
+

Connected Accounts

+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ) : existingAccounts.length > 0 && ( +

Connected Accounts

{existingAccounts.map((account) => ( @@ -365,8 +380,9 @@ export default function AccountsPage() { @@ -452,8 +468,9 @@ export default function AccountsPage() { @@ -551,8 +568,9 @@ export default function AccountsPage() { diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index eb5ff9d..819253d 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -4,7 +4,8 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import axios from 'axios' import { getDashboardSummary, DashboardSummary } from '@/lib/api' -import { formatCurrency } from '@/lib/utils' +import { formatCurrency, withMinDuration } from '@/lib/utils' +import { Skeleton, StatCardSkeleton, ChartSkeleton, RecRowSkeleton } from '@/components/ui/Skeleton' import { LineChart, Line, @@ -36,7 +37,7 @@ export default function DashboardPage() { const fetchData = async () => { try { - const summary = await getDashboardSummary() + const summary = await withMinDuration(getDashboardSummary(), 1000) setData(summary) } catch (error) { console.error('Failed to fetch dashboard data:', error) @@ -52,14 +53,6 @@ export default function DashboardPage() { fetchData() }, [router]) - if (loading) { - return ( -
-
-
- ) - } - // Prepare pie chart data const pieData = data?.cost_by_service ? Object.entries(data.cost_by_service).map(([name, value]) => ({ name, value })) @@ -93,6 +86,33 @@ export default function DashboardPage() {
+ {loading ? ( + <> + {/* Skeleton: Stat cards */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ {/* Skeleton: Charts */} +
+ + +
+ {/* Skeleton: Top recommendations */} +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + ) : ( +
{/* Stats Cards */}
@@ -206,6 +226,8 @@ export default function DashboardPage() { )}
+
+ )}
) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 656e687..a3a1a4d 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { login } from '@/lib/api' +import { Spinner } from '@/components/ui/Spinner' export default function LoginPage() { const router = useRouter() @@ -75,8 +76,9 @@ export default function LoginPage() { diff --git a/frontend/app/recommendations/page.tsx b/frontend/app/recommendations/page.tsx index ebeb8fe..dea6bbb 100644 --- a/frontend/app/recommendations/page.tsx +++ b/frontend/app/recommendations/page.tsx @@ -3,8 +3,10 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import api from '@/lib/api' -import { formatCurrency } from '@/lib/utils' +import { formatCurrency, withMinDuration } from '@/lib/utils' import { Recommendation } from '@/types' +import { RecCardSkeleton } from '@/components/ui/Skeleton' +import { Spinner } from '@/components/ui/Spinner' export default function RecommendationsPage() { const router = useRouter() @@ -12,6 +14,8 @@ export default function RecommendationsPage() { const [loading, setLoading] = useState(true) const [generating, setGenerating] = useState(false) const [filter, setFilter] = useState('all') + // id + action of the row currently being applied/dismissed (per-row loading) + const [actioning, setActioning] = useState<{ id: string; action: 'apply' | 'dismiss' } | null>(null) useEffect(() => { const token = localStorage.getItem('token') @@ -24,7 +28,7 @@ export default function RecommendationsPage() { const fetchRecommendations = async () => { try { - const response = await api.get('/recommendations') + const response = await withMinDuration(api.get('/recommendations'), 1000) setRecommendations(response.data) } catch (error) { console.error('Failed to fetch recommendations:', error) @@ -47,20 +51,26 @@ export default function RecommendationsPage() { } const handleDismiss = async (id: string) => { + setActioning({ id, action: 'dismiss' }) try { await api.patch(`/recommendations/${id}/dismiss`) setRecommendations(recommendations.filter(r => r.id !== id)) } catch (error) { console.error('Failed to dismiss recommendation:', error) + } finally { + setActioning(null) } } const handleApply = async (id: string) => { + setActioning({ id, action: 'apply' }) try { await api.post(`/recommendations/${id}/apply`) setRecommendations(recommendations.filter(r => r.id !== id)) } catch (error) { console.error('Failed to apply recommendation:', error) + } finally { + setActioning(null) } } @@ -123,10 +133,12 @@ export default function RecommendationsPage() {

@@ -170,8 +182,10 @@ export default function RecommendationsPage() { {/* Recommendations List */} {loading ? ( -
-
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))}
) : filteredRecommendations.length === 0 ? (
@@ -181,7 +195,7 @@ export default function RecommendationsPage() {

) : ( -
+
{filteredRecommendations.map((rec) => (
@@ -223,18 +237,33 @@ export default function RecommendationsPage() { {rec.status === 'pending' && (
- - + {(() => { + const isApplying = actioning?.id === rec.id && actioning.action === 'apply' + const isDismissing = actioning?.id === rec.id && actioning.action === 'dismiss' + const busy = actioning?.id === rec.id + return ( + <> + + + + ) + })()}
)}
diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 22e67c2..dea302d 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { register } from '@/lib/api' +import { Spinner } from '@/components/ui/Spinner' export default function RegisterPage() { const router = useRouter() @@ -91,8 +92,9 @@ export default function RegisterPage() { diff --git a/frontend/components/ui/Skeleton.tsx b/frontend/components/ui/Skeleton.tsx new file mode 100644 index 0000000..ffa975b --- /dev/null +++ b/frontend/components/ui/Skeleton.tsx @@ -0,0 +1,89 @@ +import { clsx } from 'clsx' + +/** + * Base skeleton placeholder. Pulses while content loads; respects + * prefers-reduced-motion (motion-reduce:animate-none). + */ +export function Skeleton({ className }: { className?: string }) { + return ( +