← back to Grant
app/river/page.tsx
113 lines
/**
* /river — "Deadline River" public visualization of federal grant
* opportunities as fish swimming toward their close-date.
*
* 2026-05-05 (tick 37): server component fetches Grants.gov public API
* directly (no auth), passes the rows to the client canvas. No new API
* surface added — the existing /api/grants/discover-real is auth-gated by
* middleware, this route bypasses by going straight to the upstream.
*/
import RiverCanvas from './RiverCanvas';
import RiverTable from './RiverTable';
// 2026-05-30 (audit P0-5): removed `dynamic = 'force-dynamic'` — it overrode
// `revalidate`, forcing a fresh outbound grants.gov fetch on EVERY request
// (unauthenticated public page = fetch-amplification surface). With only
// revalidate, the page is cached and re-fetches grants.gov at most every 10min.
export const revalidate = 600; // 10 min cache
type GrantsGovHit = {
id: string;
number?: string;
title?: string;
agencyCode?: string;
agency?: string;
openDate?: string;
closeDate?: string;
oppStatus?: string;
cfdaList?: string[];
};
async function fetchGrants(keyword = 'education'): Promise<GrantsGovHit[]> {
try {
const res = await fetch('https://api.grants.gov/v1/api/search2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, oppStatuses: 'forecasted|posted', rows: 80 }),
signal: AbortSignal.timeout(15_000),
next: { revalidate: 600 },
});
if (!res.ok) return [];
const json = await res.json();
if (json.errorcode !== 0) return [];
return json.data?.oppHits ?? [];
} catch {
return [];
}
}
export default async function RiverPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const keyword = (q || 'education').slice(0, 80);
const grants = await fetchGrants(keyword);
return (
<main style={{
background: '#07090a',
color: '#d8e8df',
fontFamily: '-apple-system, BlinkMacSystemFont, system-ui, sans-serif',
minHeight: '100vh',
padding: 0, margin: 0,
}}>
<header style={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
padding: '20px 28px', borderBottom: '1px solid #1f2925',
}}>
<div>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 600, letterSpacing: '-0.01em' }}>
<span style={{ color: '#10b981' }}>~</span>~ Deadline River
<span style={{ color: '#6c7d75', fontWeight: 400, fontSize: 14, marginLeft: 12 }}>
{grants.length} federal grant opportunities · keyword <code style={{
background: '#111', padding: '1px 6px', borderRadius: 3, color: '#d8e8df',
}}>{keyword}</code>
</span>
</h1>
</div>
<form method="get" style={{ display: 'flex', gap: 8 }}>
<input
name="q"
defaultValue={keyword}
placeholder="keyword…"
style={{
background: '#0f1311', color: '#d8e8df', border: '1px solid #1f2925',
borderRadius: 6, padding: '6px 12px', fontSize: 13, width: 200,
fontFamily: 'inherit',
}}
/>
<button type="submit" style={{
background: '#064e3b', color: '#10b981', border: '1px solid #10b981',
borderRadius: 6, padding: '6px 16px', fontSize: 13, cursor: 'pointer',
fontWeight: 500,
}}>search</button>
</form>
</header>
<RiverCanvas grants={grants} />
<RiverTable grants={grants} />
<footer style={{
padding: '16px 28px', borderTop: '1px solid #1f2925', color: '#6c7d75',
fontSize: 11, lineHeight: 1.7,
}}>
Each fish = a real federal grant opportunity. <strong style={{ color: '#d8e8df' }}>X</strong> = time-to-deadline (today→18mo). <strong style={{ color: '#d8e8df' }}>Y</strong> = funding amount (log scale). <strong style={{ color: '#d8e8df' }}>color</strong> = CFDA category prefix. <strong style={{ color: '#d8e8df' }}>silhouette</strong> = agency. Glow + speed scale with deadline urgency. Hover for detail · click to open grants.gov.
</footer>
</main>
);
}