← 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>
  );
}