fix(D1): add dashboard skeleton loaders

Replace plain loading text in news, research cards, and screener results with stable skeleton layouts so the dashboard holds its shape while market data requests resolve.

Refs: docs/AUDIT_REDESIGN.md item D1.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 16:38:17 -07:00
parent 87660c7e5d
commit 0fb1d63cbe
4 changed files with 105 additions and 18 deletions

View File

@ -0,0 +1,33 @@
import type { CSSProperties } from 'react';
interface SkeletonBlockProps {
width?: number | string;
height?: number | string;
radius?: number;
style?: CSSProperties;
}
export function SkeletonBlock({
width = '100%',
height = 12,
radius = 999,
style,
}: SkeletonBlockProps) {
return (
<span
aria-hidden="true"
style={{
display: 'block',
width,
height,
borderRadius: radius,
background: 'linear-gradient(90deg, #F3F4F6 0%, #E5E7EB 45%, #F3F4F6 100%)',
...style,
}}
/>
);
}
export function SkeletonText({ width = '70%' }: { width?: number | string }) {
return <SkeletonBlock width={width} height={10} />;
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
import { SkeletonBlock, SkeletonText } from '../Skeleton';
// ─── Portfolio Summary ────────────────────────────────────────────────────────
@ -147,6 +148,31 @@ function NewsCard({ article }: { article: NewsArticle }) {
);
}
function NewsFeedSkeleton() {
return (
<div role="status" aria-label="Loading news" style={{ padding: '4px 0 8px' }}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
display: 'flex',
gap: 10,
padding: '10px 16px',
borderBottom: i < 2 ? '1px solid #F9FAFB' : 'none',
}}
>
<SkeletonBlock width={48} height={48} radius={10} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 7, paddingTop: 3 }}>
<SkeletonText width="92%" />
<SkeletonText width="68%" />
<SkeletonText width="36%" />
</div>
</div>
))}
</div>
);
}
function NewsFeed() {
const { activeSymbol } = useAppContext();
const [news, setNews] = useState<NewsArticle[]>([]);
@ -180,9 +206,7 @@ function NewsFeed() {
</span>
</div>
{loading && (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 16px' }}>Loading news</div>
)}
{loading && <NewsFeedSkeleton />}
{!loading && error && (
<div style={{ fontSize: 12, color: '#DC2626', padding: '12px 16px' }}>{error}</div>
@ -194,7 +218,7 @@ function NewsFeed() {
</div>
)}
{news.map((a, i) => <NewsCard key={a.id ?? a.url ?? i} article={a} />)}
{!loading && news.map((a, i) => <NewsCard key={a.id ?? a.url ?? i} article={a} />)}
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
type OHLCVBar,
} from '../lib/marketApi';
import { SkeletonBlock, SkeletonText } from '../components/Skeleton';
// ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
@ -589,6 +590,7 @@ function ResearchCards({
const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date());
const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
const ValueSkeleton = ({ width = 58 }: { width?: number }) => <SkeletonBlock width={width} height={10} />;
const financialRows: [string, string][] = [
['Market Cap', fmtBig(profile?.mktCap)],
@ -610,7 +612,12 @@ function ResearchCards({
📋 Company
</div>
{profileLoading ? (
<div style={{ color: '#9CA3AF', fontSize: 12 }}>Loading</div>
<div role="status" aria-label="Loading company profile" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<SkeletonText width="70%" />
<SkeletonText width="96%" />
<SkeletonText width="88%" />
<SkeletonText width="55%" />
</div>
) : profile ? (
<>
<div style={{ fontSize: 12, color: '#374151', marginBottom: 6, lineHeight: 1.5 }}>
@ -646,7 +653,9 @@ function ResearchCards({
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{loading || profileLoading ? '…' : val}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
{loading || profileLoading ? <ValueSkeleton width={label.length > 10 ? 64 : 46} /> : val}
</span>
</div>
))}
</div>
@ -667,10 +676,12 @@ function ResearchCards({
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{val}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
{val === '…' ? <ValueSkeleton width={label === 'Next Earnings' ? 82 : 52} /> : val}
</span>
</div>
))}
{earnings.length > 0 && (
{!loading && earnings.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }}>Past Earnings</div>
{earnings.slice(0,3).map((e, i) => (

View File

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react';
import { SlidersHorizontal, Search, Loader2, RefreshCw } from 'lucide-react';
import { SlidersHorizontal, Search, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useNavigate } from 'react-router-dom';
import { getPlatformAccessToken } from '../lib/authSession';
import { tradingRuntime } from '../lib/runtime';
import { createRequestId } from '../../../shared/request-id.js';
import { SkeletonBlock } from '../components/Skeleton';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ScreenerRow {
@ -126,6 +127,32 @@ export function ScreenerView() {
</span>
);
const ScreenerSkeletonRows = () => (
<div role="status" aria-label="Loading screener results">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
style={{
display: 'grid',
gridTemplateColumns: '100px 1fr 90px 90px 110px 80px 110px',
padding: '12px 16px',
borderBottom: i < 5 ? '1px solid #F9FAFB' : 'none',
alignItems: 'center',
columnGap: 0,
}}
>
<SkeletonBlock width={54} height={13} />
<SkeletonBlock width={`${72 - (i % 3) * 8}%`} height={12} />
<SkeletonBlock width={58} height={12} />
<SkeletonBlock width={50} height={12} />
<SkeletonBlock width={74} height={12} />
<SkeletonBlock width={38} height={12} />
<SkeletonBlock width={68} height={12} />
</div>
))}
</div>
);
const handleRowClick = (symbol: string) => {
setActiveSymbol(symbol);
navigate('/');
@ -271,15 +298,7 @@ export function ScreenerView() {
</div>
{/* Loading */}
{loading && (
<div style={{
padding: 40, display: 'flex', flexDirection: 'column',
alignItems: 'center', gap: 10, color: '#9CA3AF',
}}>
<Loader2 size={28} color="#93C5FD" style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: 13 }}>Fetching screener results</span>
</div>
)}
{loading && <ScreenerSkeletonRows />}
{/* Rows */}
{!loading && filtered.map((row, i) => (