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:
parent
87660c7e5d
commit
0fb1d63cbe
33
web/src/components/Skeleton.tsx
Normal file
33
web/src/components/Skeleton.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { useAppContext } from '../../context/AppContext';
|
import { useAppContext } from '../../context/AppContext';
|
||||||
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
|
import { fetchNews, type NewsArticle as MarketNewsArticle } from '../../lib/marketApi';
|
||||||
|
import { SkeletonBlock, SkeletonText } from '../Skeleton';
|
||||||
|
|
||||||
// ─── Portfolio Summary ────────────────────────────────────────────────────────
|
// ─── 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() {
|
function NewsFeed() {
|
||||||
const { activeSymbol } = useAppContext();
|
const { activeSymbol } = useAppContext();
|
||||||
const [news, setNews] = useState<NewsArticle[]>([]);
|
const [news, setNews] = useState<NewsArticle[]>([]);
|
||||||
@ -180,9 +206,7 @@ function NewsFeed() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && <NewsFeedSkeleton />}
|
||||||
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 16px' }}>Loading news…</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<div style={{ fontSize: 12, color: '#DC2626', padding: '12px 16px' }}>{error}</div>
|
<div style={{ fontSize: 12, color: '#DC2626', padding: '12px 16px' }}>{error}</div>
|
||||||
@ -194,7 +218,7 @@ function NewsFeed() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
|
fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
|
||||||
type OHLCVBar,
|
type OHLCVBar,
|
||||||
} from '../lib/marketApi';
|
} from '../lib/marketApi';
|
||||||
|
import { SkeletonBlock, SkeletonText } from '../components/Skeleton';
|
||||||
|
|
||||||
// ─── Time period config ───────────────────────────────────────────────────────
|
// ─── Time period config ───────────────────────────────────────────────────────
|
||||||
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
|
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 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 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][] = [
|
const financialRows: [string, string][] = [
|
||||||
['Market Cap', fmtBig(profile?.mktCap)],
|
['Market Cap', fmtBig(profile?.mktCap)],
|
||||||
@ -610,7 +612,12 @@ function ResearchCards({
|
|||||||
📋 Company
|
📋 Company
|
||||||
</div>
|
</div>
|
||||||
{profileLoading ? (
|
{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 ? (
|
) : profile ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ fontSize: 12, color: '#374151', marginBottom: 6, lineHeight: 1.5 }}>
|
<div style={{ fontSize: 12, color: '#374151', marginBottom: 6, lineHeight: 1.5 }}>
|
||||||
@ -646,7 +653,9 @@ function ResearchCards({
|
|||||||
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -667,10 +676,12 @@ function ResearchCards({
|
|||||||
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{earnings.length > 0 && (
|
{!loading && earnings.length > 0 && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }}>Past Earnings</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }}>Past Earnings</div>
|
||||||
{earnings.slice(0,3).map((e, i) => (
|
{earnings.slice(0,3).map((e, i) => (
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { useAppContext } from '../context/AppContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getPlatformAccessToken } from '../lib/authSession';
|
import { getPlatformAccessToken } from '../lib/authSession';
|
||||||
import { tradingRuntime } from '../lib/runtime';
|
import { tradingRuntime } from '../lib/runtime';
|
||||||
import { createRequestId } from '../../../shared/request-id.js';
|
import { createRequestId } from '../../../shared/request-id.js';
|
||||||
|
import { SkeletonBlock } from '../components/Skeleton';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
interface ScreenerRow {
|
interface ScreenerRow {
|
||||||
@ -126,6 +127,32 @@ export function ScreenerView() {
|
|||||||
</span>
|
</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) => {
|
const handleRowClick = (symbol: string) => {
|
||||||
setActiveSymbol(symbol);
|
setActiveSymbol(symbol);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@ -271,15 +298,7 @@ export function ScreenerView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{loading && (
|
{loading && <ScreenerSkeletonRows />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
{!loading && filtered.map((row, i) => (
|
{!loading && filtered.map((row, i) => (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user