> ## Documentation Index
> Fetch the complete documentation index at: https://developers.dealroom.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Portfolio Dashboard

> Build a branded VC portfolio monitoring dashboard with React, D3, and the Dealroom API — runs entirely in the browser using Auth0 Universal Login

A portfolio monitoring dashboard for a VC fund. Paste a prompt into Claude, provide your
Dealroom **Application** API key (`client_id` only — no secret) and your fund ID, and get a
fully branded dashboard — themed from your fund's logo.

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/dealroomco-prod/mintlify/examples/portfolio-dashboard-preview.png" alt="Portfolio Dashboard Preview" />
</Frame>

## How It Works

1. [Create a **Browser app** API key](#create-a-browser-app-api-key) in your Dealroom dashboard.
2. You paste the prompt into Claude with your **Client ID** and **Investor ID**.
3. Claude generates a pure SPA — users log in with their Dealroom account, then the browser calls the API directly. No backend, no serverless proxy.
4. Claude reads your logo, extracts brand colors, and generates a personalized dashboard.

<Warning>
  Browser app keys are **read-only by design**. Tokens live in the browser —
  write operations are never allowed with this key type. For server-side writes,
  use a Programmatic key behind a proxy instead.
</Warning>

## Create a Browser app API key

Before starting, create the key your SPA will use.

<Steps>
  <Step title="Open Settings > API">
    Go to [**Settings > API**](https://app-next.dealroom.co/settings/api) in your Dealroom dashboard.
  </Step>

  <Step title="Select &#x22;Browser app&#x22;">
    On the key type selector, pick **Browser app**. A read-only banner confirms the constraint.
  </Step>

  <Step title="Name your key">
    Use something descriptive, e.g. `Portfolio Dashboard — Production`.
  </Step>

  <Step title="Add allowed origins">
    List every domain your app runs on. The API blocks requests from any other origin.

    ```text theme={null}
    http://localhost:5173
    https://portfolio-staging.yourfund.com
    https://portfolio.yourfund.com
    ```

    Include `http://localhost:5173` (or whatever port your Vite dev server uses) so you can develop locally.
  </Step>

  <Step title="Confirm callback URLs">
    Callbacks auto-fill as `<origin>/callback` when you type an origin — the form handles this for you. Override only if your app uses a different redirect path.

    ```text theme={null}
    http://localhost:5173/callback
    https://portfolio-staging.yourfund.com/callback
    https://portfolio.yourfund.com/callback
    ```
  </Step>

  <Step title="Create and copy the Client ID">
    Click **Create key**. The dashboard shows the `client_id` — copy it. There is no `client_secret` for browser app keys (that's the point: nothing sensitive ships in your bundle).
  </Step>
</Steps>

<Tip>
  Need a new environment later? Open the key, edit it, and add the new origin +
  callback URL. No need to create another key per deploy target.
</Tip>

## The Prompt

<Accordion title="Copy the prompt">
  ```text theme={null}
  # Build a Dealroom Portfolio Dashboard (Browser SPA)

  Create a **VC portfolio monitoring dashboard** using React + Vite + TypeScript that connects
  directly to the Dealroom API from the browser via Auth0 Universal Login. The app is a pure
  SPA — no backend, no serverless proxy. Deployable as static assets to Vercel, Netlify,
  Cloudflare Pages, or S3/CloudFront.

  ## Tech Stack (pinned versions)

  | Library              | Version | Purpose                                    |
  | -------------------- | ------- | ------------------------------------------ |
  | React                | 19.1    | UI framework                               |
  | TypeScript           | 5.8     | Type safety                                |
  | @auth0/auth0-spa-js  | 2       | OAuth2 PKCE for browser login              |
  | Tailwind CSS         | 4.0     | Utility-first styling (PostCSS)            |
  | shadcn/ui            | latest  | Radix UI-based component library           |
  | Framer Motion        | 12      | Animations and transitions                 |
  | TanStack Query       | v5      | Server state management                    |
  | D3                   | 7       | Charts and data visualization              |
  | MapLibre GL          | 5       | Interactive world map                      |
  | Vite                 | 6       | Build tooling                              |

  Use these exact major versions. Initialize with:

    npm create vite@latest portfolio-dashboard -- --template react-ts

  Then install:

    npm install react@^19.1 react-dom@^19.1 @auth0/auth0-spa-js@^2 @tanstack/react-query@^5 \
      d3@^7 @types/d3@^7 maplibre-gl@^5 framer-motion@^12 tailwindcss@^4 @tailwindcss/postcss \
      clsx tailwind-merge
    npx shadcn@latest init


  ## API Version Pinning

  CRITICAL: Pin the API version on every request via the API-Version header — otherwise
  responses follow the latest, which may change without notice.

  Ask the user which version to pin (format: YYYY-MM-DD). If they don't specify, default
  to a recent stable version.

  Create a constants file:

    // src/lib/constants.ts
    export const DEALROOM_API_VERSION = "<YYYY-MM-DD provided by user>";
    export const API_VERSION_HEADER = "API-Version";


  ## Step 0: Discover the API via OpenAPI (DO THIS FIRST)

  NEVER hardcode endpoint paths from memory. The Dealroom API publishes a complete
  OpenAPI 3.1 specification — fetch it and treat it as the single source of truth for
  every path, parameter, filter, and response schema you use below.

    curl -s https://api-next.dealroom.co/openapi -o openapi.json

  ### Example — find the "load a single investor" endpoint

  Search the spec for investor-related paths:

    jq '.paths | to_entries[] | select(.key | test("/api/data/investors")) | {path: .key, methods: (.value | keys)}' openapi.json

  You'll see something like:

    { "path": "/api/data/investors",      "methods": ["get"] }   // list investors
    { "path": "/api/data/investors/{id}", "methods": ["get"] }   // get one investor

  Inspect the full schema (parameters, response, filters) for the one you want:

    jq '.paths["/api/data/investors/{id}"]' openapi.json

  Then construct the full request URL by prepending the API base:

    GET https://api-next.dealroom.co/api/data/investors/{INVESTOR_ID}

  Apply this exact pattern for EVERY resource you use (investors, transactions,
  entities, aggregations, taxonomies). When in doubt about a path, filter name, or
  parameter, open openapi.json and confirm — do not guess.


  ## Step 1: Discover Your Client

  Before writing any runtime code, fetch real data about the investor so you can personalize
  the dashboard (logo, name, tagline, portfolio stats, location). Do this discovery once
  via curl — the runtime SPA will re-fetch the same data via the user's token.

  ### 1a. Ask the user for their inputs

  Before writing any code, collect these from the user — do not proceed until you have all
  four. These come from the "Create a Browser app API key" flow in the Dealroom dashboard
  (Settings > API). Browser app keys have NO client_secret; do not ask for one.

  - CLIENT_ID — the Dealroom Browser app API key's client ID (public value, safe to ship)
  - INVESTOR_URL — the Dealroom investor profile URL for their fund
    (e.g. `https://app-next.beta.dealroom.co/investors/33572`).
    DO NOT ask the user for a numeric ID. After they paste the URL, extract the trailing
    numeric segment yourself with e.g.:

      INVESTOR_ID=$(echo "$INVESTOR_URL" | sed -E 's|.*/investors/([0-9]+).*|\1|')

    and use INVESTOR_ID internally for all subsequent API calls.
  - DEPLOY_ORIGIN — the URL where the app will run (e.g. `http://localhost:5173` for local,
    `https://portfolio.myfund.com` for production). Must already be registered in the key's
    Allowed origins.
  - CALLBACK_URL — typically `<DEPLOY_ORIGIN>/callback`, must be registered in the key's
    Allowed callback URLs.

  Auth0 tenant values are fixed for the platform — use them as given:
  - AUTH0_DOMAIN = `accounts.dealroom.co`
  - API_AUDIENCE = `https://api-next.dealroom.co`

  If the user has not created a Browser app key yet, stop and walk them through it: have
  them open Dealroom Settings > API, select "Browser app" key type, name it, add their
  app's origins (e.g. http://localhost:5173 for local dev), and copy the client_id.

  ### 1b. Get a one-time token for discovery

  For THIS curl-based discovery step only, ask the user to grab a short-lived bearer token
  from their browser (open the Dealroom dashboard, copy the `Authorization` header from any
  API request in DevTools' Network tab). Use it to fetch the investor profile below. This
  is a one-off — the generated SPA handles its own auth via Universal Login at runtime.

  ### 1c. Fetch the Investor Profile

  Use the path you discovered in Step 0 (`/api/data/investors/{id}`):

    curl -s "https://api-next.dealroom.co/api/data/investors/{INVESTOR_ID}" \
      -H "Authorization: Bearer {USER_TOKEN}" \
      -H "API-Version: <version from changelog>" \
      -H "X-Client-Id: {CLIENT_ID}" | jq .data

  This returns the investor's real data including:
  - name, image (logo URL), tagline
  - hq_country, hq_city
  - investments: { total_count, total_invested, preferred_round }
  - portfolio: { companies, total_rounds }
  - tags (e.g. "Venture Capital")

  ### 1d. Fetch the Logo and Read Its Colors

  Download the logo from the `image` URL and analyze its dominant colors:

    curl -s -o logo.png "{IMAGE_URL_FROM_RESPONSE}"

  Read the downloaded logo image. Note the 2-3 dominant colors you see.

  ### 1e. Use This Context

  Now you know the investor's:
  - Name and tagline → use in the Header component, page title, and meta tags
  - Logo URL → hardcode as the default in the Header
  - Dominant colors → set as the initial CSS custom property values in your theme
    so the dashboard looks branded even before the runtime color extraction runs
  - Portfolio stats (total invested, deal count, companies, preferred round) →
    use to pick appropriate number formatting and chart ranges
  - Location (hq_country, hq_city) → use for the map's initial viewport center
  - Tags → use to inform the dashboard subtitle or category label

  This is the most important step. The dashboard should feel like it was hand-built
  for this specific investor, not a generic template with a logo swapped in.


  ## Step 2: Auth0 SPA Setup

  The SPA authenticates users via Auth0 Authorization Code + PKCE. No secrets in the
  bundle; users sign in with their Dealroom account; the SPA gets a short-lived access
  token scoped to read permissions (application keys are read-only by design).

    // src/lib/auth.ts
    import { createAuth0Client, type Auth0Client } from "@auth0/auth0-spa-js";

    let client: Auth0Client | null = null;

    export async function getAuth0(): Promise<Auth0Client> {
      if (client) return client;
      client = await createAuth0Client({
        domain: import.meta.env.VITE_AUTH0_DOMAIN,
        clientId: import.meta.env.VITE_CLIENT_ID,
        authorizationParams: {
          audience: import.meta.env.VITE_API_AUDIENCE,
          redirect_uri: window.location.origin + "/callback",
        },
        cacheLocation: "localstorage",
        useRefreshTokens: true,
      });
      return client;
    }

    export async function login() {
      const auth0 = await getAuth0();
      await auth0.loginWithRedirect();
    }

    export async function handleCallback() {
      const auth0 = await getAuth0();
      await auth0.handleRedirectCallback();
      window.history.replaceState({}, document.title, "/");
    }

    export async function getToken(): Promise<string> {
      const auth0 = await getAuth0();
      return auth0.getTokenSilently();
    }

    export async function isAuthenticated(): Promise<boolean> {
      const auth0 = await getAuth0();
      return auth0.isAuthenticated();
    }

  Environment variables (committed to `.env.example`, set per environment in the host):
  - VITE_AUTH0_DOMAIN — e.g. `accounts.dealroom.co`
  - VITE_CLIENT_ID — the Application API key's client_id (public; safe to ship)
  - VITE_API_AUDIENCE — `https://api-next.dealroom.co`
  - VITE_INVESTOR_ID — the fund's Dealroom entity ID
  - VITE_API_BASE — `https://api-next.dealroom.co`

  ### Auth gate in main.tsx

    // src/main.tsx
    import { createRoot } from "react-dom/client";
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { App } from "./App";
    import { handleCallback, isAuthenticated, login } from "./lib/auth";
    import "./app.css";

    const qc = new QueryClient();

    async function bootstrap() {
      if (window.location.pathname === "/callback") {
        await handleCallback();
      }
      if (!(await isAuthenticated())) {
        await login();
        return;
      }
      createRoot(document.getElementById("root")!).render(
        <QueryClientProvider client={qc}><App /></QueryClientProvider>
      );
    }

    bootstrap();

  Users hitting the app are redirected to Auth0 Universal Login, sign in with their
  Dealroom account, and land back on the dashboard with a valid access token.


  ## Step 3: Investor Branding (Runtime)

  At runtime, the app fetches the investor profile directly from the Dealroom API and
  extracts branding dynamically.

    GET https://api-next.dealroom.co/<investor path from openapi.json>

  Use the `image` URL for runtime color extraction:
  1. Display the logo in the header
  2. Extract 2-3 dominant colors (canvas-based: load image, draw, sample pixels, cluster)
  3. Override shadcn/ui CSS custom properties:
      - --primary — most prominent non-white/non-black color (HSL format)
      - --secondary — second most prominent color
      - --accent — lighter tint of primary
      - --chart-1 through --chart-5 — palette for D3 charts
      - Keep shadcn defaults for --background, --foreground, --muted, --border
  4. The initial theme (from Step 1 colors) is already close — runtime extraction
      is a progressive enhancement


  ## Step 4: Dashboard Data (TanStack Query)

  All data fetching uses TanStack Query v5. Wrap the app in QueryClientProvider in
  main.tsx (already done above), then use useQuery hooks in each component.

  The SPA calls the Dealroom API **directly** from the browser. No proxy. Tokens come
  from `getToken()` (auth0-spa-js, auto-refreshed via refresh tokens).

  Before writing any hook below, look up the endpoint and its filters in openapi.json
  (see Step 0). The examples show the STRUCTURE to build — verify every path, filter,
  metric, and group_by key against the spec before using them:

    jq '.paths | keys' openapi.json                               # all endpoints
    jq '.paths["/api/data/transactions"].get.parameters' openapi.json  # params for one endpoint
    jq '.paths["/api/analytics/aggregate/funding-rounds"]' openapi.json     # aggregate spec

  ### API Client

    // src/lib/api.ts
    import { DEALROOM_API_VERSION, API_VERSION_HEADER } from "./constants";
    import { getToken } from "./auth";

    const API_BASE = import.meta.env.VITE_API_BASE;
    const CLIENT_ID = import.meta.env.VITE_CLIENT_ID;

    export async function apiFetch<T>(path: string, params?: Record<string, string>): Promise<T> {
      const url = new URL(path, API_BASE);
      if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
      const token = await getToken();
      const res = await fetch(url, {
        headers: {
          Authorization: `Bearer ${token}`,
          [API_VERSION_HEADER]: DEALROOM_API_VERSION,
          "X-Client-Id": CLIENT_ID,
        },
      });
      if (!res.ok) throw new Error(`API error: ${res.status}`);
      return res.json();
    }

  ### Query Hooks

    // src/hooks/useInvestor.ts
    import { useQuery } from "@tanstack/react-query";
    import { apiFetch } from "../lib/api";

    export function useInvestor(investorId: string) {
      return useQuery({
        queryKey: ["investor", investorId],
        queryFn: () => apiFetch(`/api/data/investors/${investorId}`),
        staleTime: 5 * 60 * 1000,
      });
    }

    // src/hooks/useDashboard.ts — one useQuery per chart, all fire in parallel
    import { useQuery } from "@tanstack/react-query";
    import { apiFetch } from "../lib/api";

    const INV_FILTER = (id: string) => `investor_id[eq]:${id}`;

    export function useTransactions(investorId: string) {
      return useQuery({
        queryKey: ["transactions", investorId],
        queryFn: () => apiFetch("/api/data/transactions", {
          filter: INV_FILTER(investorId), sort: "-year", limit: "100",
        }),
        staleTime: 2 * 60 * 1000,
      });
    }

    export function useFundingByYear(investorId: string) {
      return useQuery({
        queryKey: ["funding-by-year", investorId],
        queryFn: () => apiFetch("/api/analytics/aggregate/funding-rounds", {
          metric: "sum:amount_usd", group_by: "year",
          filter: INV_FILTER(investorId), sort: "-year", limit: "20",
        }),
        staleTime: 5 * 60 * 1000,
      });
    }

    export function useRoundTypes(investorId: string) {
      return useQuery({
        queryKey: ["round-types", investorId],
        queryFn: () => apiFetch("/api/analytics/aggregate/funding-rounds", {
          metric: "count", group_by: "round_type",
          filter: INV_FILTER(investorId), sort: "-count", limit: "10",
        }),
        staleTime: 5 * 60 * 1000,
      });
    }

    export function useGeography(investorId: string) {
      return useQuery({
        queryKey: ["geography", investorId],
        queryFn: () => apiFetch("/api/analytics/aggregate/funding-rounds", {
          metric: "sum:amount_usd", group_by: "hq_country",
          filter: INV_FILTER(investorId), sort: "-sum", limit: "50",
        }),
        staleTime: 5 * 60 * 1000,
      });
    }

    export function useSectors(investorId: string) {
      return useQuery({
        queryKey: ["sectors", investorId],
        queryFn: () => apiFetch("/api/analytics/aggregate/funding-rounds", {
          metric: "count", group_by: "sector",
          filter: INV_FILTER(investorId), sort: "-count", limit: "10",
        }),
        staleTime: 5 * 60 * 1000,
      });
    }

  Each component calls its own useQuery hook. Because they all render in the same tree
  on mount, TanStack Query fires them in parallel automatically. Each chart independently
  shows its loading/error/success state.

  ### API Response Shapes

  Transactions (GET /transactions?filter=investor_id[eq]:{ID}&sort=-year&limit=100):
    {
      "data": [{
        "id": 456, "amount": "5000000.00", "year": 2024, "month": 3,
        "round_type": "SEED",
        "company": { "id": 789, "name": "Acme Corp", "image": "https://...", "hq_country": "Netherlands" },
        "investors": [{ "id": 123, "name": "Y Combinator", "is_lead": true }]
      }],
      "page": { "limit": 100, "offset": 0, "total": 4200 }
    }

  Funding by year (GET /aggregate/funding-rounds?metric=sum:amount_usd&group_by=year&...):
    { "data": [{ "year": 2024, "sum": 1250000000 }, { "year": 2023, "sum": 980000000 }] }

  Round types (GET /aggregate/funding-rounds?metric=count&group_by=round_type&...):
    { "data": [{ "round_type": "SEED", "count": 2100 }, { "round_type": "SERIES_A", "count": 890 }] }

  Geography (GET /aggregate/funding-rounds?metric=sum:amount_usd&group_by=hq_country&...):
    { "data": [{ "hq_country": "United States", "sum": 6200000000 }, { "hq_country": "India", "sum": 450000000 }] }

  Sectors (GET /aggregate/funding-rounds?metric=count&group_by=sector&...):
    { "data": [{ "sector": "Enterprise Software", "count": 680 }, { "sector": "Fintech", "count": 420 }] }


  ## Step 5: Project Structure

    portfolio-dashboard/
    ├── src/
    │   ├── main.tsx                  # Entry point: auth gate → QueryClientProvider → App
    │   ├── App.tsx                   # Root layout, data orchestration
    │   ├── lib/
    │   │   ├── auth.ts               # @auth0/auth0-spa-js wrapper
    │   │   ├── constants.ts          # DEALROOM_API_VERSION, API_VERSION_HEADER
    │   │   ├── api.ts                # apiFetch wrapper with auth + version header
    │   │   ├── colors.ts             # Logo color extraction + theme generation
    │   │   └── format.ts             # Number formatting ($1.2B, 4,200, etc.)
    │   ├── hooks/
    │   │   ├── useInvestor.ts        # useQuery: investor profile + branding
    │   │   └── useDashboard.ts       # useQuery per chart (parallel on mount)
    │   ├── components/
    │   │   ├── ui/                   # shadcn/ui components (Button, Card, Table, etc.)
    │   │   ├── Header.tsx            # Logo, name, tagline, logout — Framer Motion entrance
    │   │   ├── KpiCards.tsx          # shadcn Cards with animated counters
    │   │   ├── PortfolioTable.tsx    # shadcn Table, sortable columns
    │   │   ├── FundingByYear.tsx     # D3 bar chart (SVG)
    │   │   ├── RoundTypeChart.tsx    # D3 donut chart (SVG)
    │   │   ├── GeographyMap.tsx      # MapLibre GL choropleth
    │   │   ├── SectorTreemap.tsx     # D3 treemap (SVG)
    │   │   └── ThemeProvider.tsx     # Applies extracted colors to shadcn CSS vars
    │   ├── app.css                   # Tailwind v4 directives + shadcn theme vars
    │   └── components.json           # shadcn/ui config
    ├── postcss.config.js
    ├── .env.example                  # VITE_AUTH0_DOMAIN, VITE_CLIENT_ID, VITE_API_AUDIENCE, etc.
    ├── vite.config.ts
    ├── tsconfig.json
    └── package.json

  No `api/`, `netlify/functions/`, or `functions/` directories — this is a pure SPA.


  ## Step 6: Implementation Requirements

  ### Color Extraction
  - Canvas-based: load logo, draw to canvas, sample pixels
  - Ignore near-white (#f0f0f0+) and near-black (#101010-)
  - K-means or frequency clustering for 2-3 dominant colors
  - Map to shadcn CSS custom properties (HSL format)
  - Apply via ThemeProvider on <html> element

  ### D3 Charts
  - Bar chart (FundingByYear): d3.scaleBand + d3.scaleLinear, SVG rects, Framer Motion animate, hover tooltip
  - Donut chart (RoundTypeChart): d3.pie() + d3.arc(), center label with total, hover highlights segment
  - Treemap (SectorTreemap): d3.treemap() + d3.hierarchy(), labeled rectangles, brand palette
  - All responsive via ResizeObserver
  - Color scale: d3.scaleOrdinal() with 5 extracted chart colors

  ### MapLibre GL World Map
  - Free vector tiles (OpenFreeMap or MapTiler free tier)
  - Choropleth: join aggregate data by country name to GeoJSON
  - d3.scaleSequential mapped from brand primary
  - Hover popup: country name + total invested
  - Fit bounds to countries with data

  ### Data Attribution
  Every chart and the map MUST include a small "Powered by Dealroom" attribution in the
  bottom-right corner. Use the white Dealroom logo on a semi-transparent dark pill:

    Logo URL: https://dealroom.co/images/DR_logo_white@8x.png

  Implementation:
  - Create a reusable DealroomAttribution component
  - Render as a small pill (e.g. 24px height) with the logo + "Dealroom" text
  - Position: absolute bottom-right of each chart/map container
  - Style: semi-transparent dark background (rgba(0,0,0,0.6)), rounded, white text
  - Link the pill to https://dealroom.co

  ### Number Formatting
  - Currency: $1.2B, $450M, $5.0M (compact, 1 decimal)
  - Counts: 4,200 (comma-separated)
  - Percentages: 42.5% (1 decimal)

  ### Portfolio Table
  - shadcn Table component, sortable columns
  - Columns: Company (logo + name), Country, Round Type, Amount, Year
  - Link company names to https://app.dealroom.co/companies/{slug}

  ### KPI Cards
  - shadcn Card + Framer Motion animated number count-up
  - Total Invested, Total Deals, Portfolio Companies, Preferred Round

  ### Layout & States
  - Tailwind v4 for layout, Framer Motion staggered entrance animations
  - Loading: TanStack Query isPending → shadcn Skeleton
  - Error: TanStack Query isError → shadcn Alert with refetch() retry
  - 401 from API → call `login()` again (token refresh failed)
  - Responsive: 2-col desktop, 1-col tablet, stacked mobile

  ### Header Logout
  - Include a "Sign out" action in the header menu
  - Calls `auth0.logout({ logoutParams: { returnTo: window.location.origin } })`


  ## Step 7: Configuration

    // src/lib/constants.ts
    export const DEALROOM_API_VERSION = "<version from changelog>";
    export const API_VERSION_HEADER = "API-Version";

    // .env.example
    VITE_AUTH0_DOMAIN=accounts.dealroom.co
    VITE_CLIENT_ID=<your application key client_id>
    VITE_API_AUDIENCE=https://api-next.dealroom.co
    VITE_API_BASE=https://api-next.dealroom.co
    VITE_INVESTOR_ID=<your Dealroom investor entity id>


  ## Step 8: Deploy

  Static asset deploys — no functions needed:

  Vercel:     vercel deploy
  Netlify:    netlify deploy --prod --dir=dist
  Cloudflare: wrangler pages deploy dist

  Set env vars (VITE_*) in each platform's dashboard. Make sure the deploy URL is
  registered in the Application API key's `allowed_origins` and `allowed_callback_urls`
  (both production and any preview domains).
  ```
</Accordion>

## Extend It

* **Sparklines** per portfolio company (funding history over time)
* **Date range filter** across all charts
* **PDF export** with `pdf-lib` + `html-to-image`
* **Fund comparison** — side-by-side view of two investors
* **Geo drill-down** — click a country to filter table and charts
