Who is the dashboard for?
A caseworker or a deputy minister will never read a DoubleML summary table. They need to enter a case, see the expected effect in dollars, compare it with the cost, and get a clear recommendation with the uncertainty stated in words. The dashboard is where the econometrics becomes decision intelligence.
Scaffold the frontend
In your Codespace (or locally, with Node.js 20+), from the repository root:
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir
cd frontend
npm install recharts
Create frontend/.env.local so the frontend knows where the API runs:
NEXT_PUBLIC_CAUSAL_API_URL=http://localhost:8000
Never hard-code the API address. Locally it is localhost:8000; in production it is your hosted API. One
environment variable per environment keeps the same code working everywhere.
The intervention simulator component
// frontend/src/components/InterventionSimulator.tsx
"use client";
import { useState } from "react";
import ImpactChart from "./ImpactChart";
type Profile = { poverty_index: number; years_education: number; unemployment_duration_months: number };
type Result = {
expected_wage_lift_cad: number;
subsidy_cost_cad: number;
net_benefit_cad: number;
recommend_intervention: boolean;
average_effect_cad: number;
model_version: string;
};
const API = process.env.NEXT_PUBLIC_CAUSAL_API_URL ?? "http://localhost:8000";
export default function InterventionSimulator() {
const [profile, setProfile] = useState<Profile>({ poverty_index: 1.2, years_education: 10, unemployment_duration_months: 14 });
const [result, setResult] = useState<Result | null>(null);
const [error, setError] = useState<string | null>(null);
async function evaluate() {
setError(null);
try {
const res = await fetch(`${API}/predict_impact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile),
});
if (!res.ok) throw new Error(`API returned ${res.status}`);
setResult(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Request failed");
}
}
const field = (key: keyof Profile, label: string, step = 1) => (
<label className="flex flex-col gap-1 text-sm font-medium text-slate-700">
{label}
<input type="number" step={step} value={profile[key]}
onChange={(e) => setProfile({ ...profile, [key]: Number(e.target.value) })}
className="rounded border border-slate-300 p-2" />
</label>
);
return (
<div className="mx-auto max-w-2xl space-y-5 rounded-xl bg-white p-6 shadow">
<h2 className="text-xl font-bold text-slate-800">Retraining subsidy: impact simulator</h2>
<div className="grid gap-3 sm:grid-cols-3">
{field("unemployment_duration_months", "Months unemployed")}
{field("years_education", "Years of education")}
{field("poverty_index", "Poverty index", 0.1)}
</div>
<button onClick={evaluate} className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
Simulate causal impact
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
{result && (
<div className={`rounded-lg border p-4 ${result.recommend_intervention ? "border-green-300 bg-green-50" : "border-amber-300 bg-amber-50"}`}>
<p className="text-lg font-semibold">
Expected wage gain: ${result.expected_wage_lift_cad.toLocaleString("en-CA")} per year
</p>
<p className="text-sm">
{result.recommend_intervention ? "Recommend the subsidy" : "Do not recommend"}: net benefit
${result.net_benefit_cad.toLocaleString("en-CA")} after a cost of ${result.subsidy_cost_cad.toLocaleString("en-CA")}.
</p>
<p className="mt-1 text-xs text-slate-500">
Average effect across all applicants: ${result.average_effect_cad.toLocaleString("en-CA")} · model {result.model_version}
</p>
<ImpactChart predictedLift={result.expected_wage_lift_cad} baseline={28000} policyCost={result.subsidy_cost_cad} />
</div>
)}
</div>
);
}
The impact chart
The chart compares the status quo (the counterfactual) with the intervention and draws a red line at the break-even point: baseline plus cost. A bar above the line pays for itself.
// frontend/src/components/ImpactChart.tsx
"use client";
import { Bar, BarChart, CartesianGrid, Legend, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
type Props = { predictedLift: number; baseline: number; policyCost: number };
const cad = (v: number) => new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);
export default function ImpactChart({ predictedLift, baseline, policyCost }: Props) {
const data = [
{ scenario: "Status quo", outcome: baseline },
{ scenario: "With subsidy", outcome: baseline + predictedLift },
];
return (
<div className="mt-4 h-72 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 20, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="scenario" />
<YAxis tickFormatter={(v) => `$${Math.round(v / 1000)}k`} domain={[baseline * 0.9, "auto"]} />
<Tooltip formatter={(v) => cad(Number(v))} />
<Legend />
<ReferenceLine y={baseline + policyCost} stroke="#ef4444" strokeDasharray="4 4" label="Break-even" />
<Bar dataKey="outcome" name="Expected annual earnings" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
Use it in the home page:
// frontend/src/app/page.tsx
import InterventionSimulator from "@/components/InterventionSimulator";
export default function Home() {
return (
<main className="min-h-screen bg-slate-100 p-8">
<InterventionSimulator />
</main>
);
}
Start both halves (two terminals) and try different profiles:
uvicorn backend.app:app --reload --port 8000 # terminal 1, repository root
cd frontend && npm run dev # terminal 2, then open http://localhost:3000
Deploying both halves
Frontend on Vercel
- Push the repository to GitHub.
- On vercel.com: Add New → Project, import the repository, set Root Directory to
frontend. - Add the environment variable
NEXT_PUBLIC_CAUSAL_API_URLwith your API's public URL. Deploy. - Every push to
mainredeploys automatically.
API on a Python host (Render, Railway, Fly.io, Google Cloud Run…)
- Add
backend/requirements.txt(fastapi, uvicorn, scikit-learn, pandas, joblib) with pinned versions. - Start command:
uvicorn backend.app:app --host 0.0.0.0 --port $PORT. - In production, restrict CORS
allow_originsto your Vercel domain instead of*.
Extend the simulator with a coverage planner: a slider for the budget share (10–100%) that calls the
/predict_batch endpoint from Lesson 15.1's exercise on a sample of applicants and plots total net benefit
against coverage with a Recharts line chart.
Checklist and recap
- My dashboard posts a profile to the API and shows the effect, cost, net benefit and recommendation.
- The chart shows the counterfactual, the intervention and the break-even line.
- The API address is an environment variable, and both halves are deployed.