Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"fixtures": "bun scripts/write-wizard-fixtures.ts"
"fixtures": "bun scripts/write-wizard-fixtures.ts",
"faq-numbers": "bun scripts/write-faq-numbers.ts"
},
"dependencies": {
"@policyengine/ui-kit": "^0.12.0",
Expand Down
75 changes: 75 additions & 0 deletions frontend/scripts/write-faq-numbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Run the FAQ's example households through the GiveCalc API and write the
* answers to src/lib/faqNumbers.json, which the FAQ reads:
*
* bun run faq-numbers # the production API
* GIVECALC_API_URL=http://localhost:8000 bun run faq-numbers
*
* Each request is the one the calculator sends for that household
* (src/lib/faqCases.ts). The file records the API and the model versions
* modal_app.py pins, which the production API runs once main deploys.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { toCalculateRequest } from "../src/lib/household";
import {
UK_FAQ_CASES,
US_BUNCHING_CASES,
US_FAQ_CASES,
type UKFaqCase,
type USFaqCase,
} from "../src/lib/faqCases";

const PRODUCTION_API = "https://policyengine--givecalc-fastapi-app.modal.run";
const api = process.env.GIVECALC_API_URL || PRODUCTION_API;
// api.ts reads its URL when it loads.
process.env.NEXT_PUBLIC_API_URL = api;
const { calculateSavings, calculateUKSavings, ukApiRequest } = await import(
"../src/lib/api"
);

function pinnedVersion(name: string): string {
const modalApp = readFileSync(
fileURLToPath(new URL("../../modal_app.py", import.meta.url)),
"utf8",
);
const match = new RegExp(`"${name}==([^"]+)"`).exec(modalApp);
if (!match) throw new Error(`modal_app.py does not pin ${name}`);
return match[1];
}

// A cold API container takes about a minute; wake it before timing out.
await fetch(`${api}/api/health`, { signal: AbortSignal.timeout(300_000) });

const us: Record<string, unknown> = {};
for (const name of Object.keys(US_FAQ_CASES) as USFaqCase[]) {
const request = {
...toCalculateRequest(US_FAQ_CASES[name]),
...(US_BUNCHING_CASES.has(name) ? { bunching: true } : {}),
};
const response = await calculateSavings(request);
us[name] = { request, response };
console.log(`${name}: ${JSON.stringify(response)}`);
}

const uk: Record<string, unknown> = {};
for (const name of Object.keys(UK_FAQ_CASES) as UKFaqCase[]) {
const request = ukApiRequest(UK_FAQ_CASES[name]);
const response = await calculateUKSavings(request);
uk[name] = { request, response };
console.log(`${name}: ${JSON.stringify(response)}`);
}

const out = fileURLToPath(new URL("../src/lib/faqNumbers.json", import.meta.url));
const numbers = {
api,
generated: new Date().toISOString().slice(0, 10),
models: {
"policyengine-us": pinnedVersion("policyengine-us"),
"policyengine-uk": pinnedVersion("policyengine-uk"),
},
us,
uk,
};
writeFileSync(out, `${JSON.stringify(numbers, null, 2)}\n`);
console.log(`Wrote ${out}`);
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import InputForm from "./components/InputForm";
import UKInputForm from "./components/UKInputForm";
import Results from "./components/Results";
import TaxInfo from "./components/TaxInfo";
import Faq from "./components/faq/Faq";
import {
useStates,
useCalculateDonation,
Expand Down Expand Up @@ -532,6 +533,7 @@ function Calculator() {
}
/>
<div className="lg:hidden">{taxInfo}</div>
<Faq />
</div>
</div>
</main>
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function Header() {
/>

<div className="relative max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<div className="flex items-center gap-4">
{/* Logo with refined typography */}
<h1 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
Expand All @@ -43,10 +43,18 @@ export default function Header() {
</a>
</div>

{/* Tagline with subtle animation */}
<p className="text-sm text-white/70 hidden sm:block font-medium">
Calculate how charitable giving affects your taxes
</p>
<div className="flex items-center gap-6">
{/* Tagline with subtle animation */}
<p className="text-sm text-white/70 hidden md:block font-medium">
Calculate how charitable giving affects your taxes
</p>
<a
href="#questions"
className="text-sm font-medium text-white/90 hover:text-white underline-offset-4 hover:underline"
>
Questions
</a>
</div>
</div>
</div>

Expand Down
170 changes: 170 additions & 0 deletions frontend/src/components/__tests__/Faq.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* The FAQ: what it renders, and that its numbers are the API's.
*
* The figures come from faqNumbers.json, written from production API runs
* by `bun run faq-numbers`; lib/__tests__/faqNumbers.test.ts checks that
* file describes the households the answers name.
*/

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import Faq from "../faq/Faq";
import { FAQ_ITEMS, exampleHref } from "../faq/questions";
import { costHeadline } from "../Results";
import { US_FAQ_CASES } from "../../lib/faqCases";
import numbers from "../../lib/faqNumbers.json";
import { toSearchParams } from "../../lib/urlState";

function renderFaq() {
render(<Faq />);
return screen.getByRole("region", { name: "Questions" });
}

/** The answer text under a question, read from the DOM. */
function answer(question: string): string {
const summary = screen.getByText(question).closest("summary");
const details = summary?.parentElement;
if (!details) throw new Error(`No question "${question}"`);
return (details.textContent ?? "").slice(question.length);
}

describe("FAQ", () => {
it("renders every question as a closed disclosure", () => {
const region = renderFaq();
const disclosures = region.querySelectorAll("details");
expect(disclosures).toHaveLength(FAQ_ITEMS.length);
expect(FAQ_ITEMS.length).toBeGreaterThanOrEqual(6);
expect(FAQ_ITEMS.length).toBeLessThanOrEqual(9);
disclosures.forEach((details, index) => {
expect(details.open).toBe(false);
const summary = details.querySelector("summary");
expect(summary?.textContent).toBe(FAQ_ITEMS[index].question);
});
});

it("opens an answer when its question is activated", async () => {
renderFaq();
const question = "How does the bunching check work?";
const details = screen.getByText(question).closest("details");
expect(details?.open).toBe(false);
await userEvent.click(screen.getByText(question));
expect(details?.open).toBe(true);
});

it("has unique ids for its questions", () => {
const ids = FAQ_ITEMS.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
});

it("uses sentence case, no emoji and no pledge wording", () => {
const region = renderFaq();
const text = region.textContent ?? "";
expect(text).not.toMatch(/pledge/i);
expect(text).not.toMatch(/\p{Extended_Pictographic}/u);
// Words after the first that may be capitalized: names and quoted labels.
const proper = new Set(["GiveCalc", "Gift", "Aid", "Share"]);
for (const { question } of FAQ_ITEMS) {
const words = question.replace(/[^\p{L}\d\s-]/gu, "").split(/\s+/);
expect(words[0][0]).toMatch(/\p{Lu}|\d/u);
for (const word of words.slice(1)) {
if (!proper.has(word)) expect(word, question).not.toMatch(/^\p{Lu}/u);
}
}
});

it("quotes the API's California figures", () => {
renderFaq();
expect(answer("Why did my gift save little or nothing?")).toContain(
"A single 30-year-old in California earning $75,000 saves $220 by giving $1,000 in 2026, and the same $220 by giving $5,000. In 2025, the $1,000 gift saved nothing.",
);
expect(numbers.us.california.response.tax_savings).toBe(220);
expect(numbers.us.californiaLarger.response.tax_savings).toBe(220);
expect(numbers.us.california2025.response.tax_savings).toBe(0);
});

it("quotes the API's top-bracket figures", () => {
renderFaq();
const text = answer("What changed for 2026?");
expect(text).toContain(
"Giving $110,000 in 2026 saves $3,500 more than giving $100,000: 35 cents for each extra dollar.",
);
const extra =
numbers.us.texasMore.response.tax_savings -
numbers.us.texas.response.tax_savings;
expect(Math.round(extra)).toBe(3_500);
expect(Math.round((100 * extra) / 10_000)).toBe(35);
});

it("quotes the API's bunching figures", () => {
renderFaq();
expect(answer("How does the bunching check work?")).toContain(
"Giving $30,000 in 2026 and nothing in 2027 saves $2,820 more in tax than giving $15,000 in each year.",
);
expect(Math.round(numbers.us.newYork.response.bunching.bunching_gain)).toBe(
2_820,
);
});

it("quotes the API's Gift Aid figures, as the results card does", () => {
renderFaq();
const text = answer("How does Gift Aid work in GiveCalc?");
expect(text).toContain(
"25p for every £1, so a £1,000 gift gives the charity £1,250.",
);
expect(text).toContain(
"earning £70,000 who gives £1,000 in 2026/27 saves £250. One earning £35,000 saves nothing",
);
expect(numbers.uk.higherRate.response.tax_savings).toBe(250);
expect(numbers.uk.basicRate.response.tax_savings).toBe(0);
// The charity's £1,250 is the figure the results headline gives.
const { headline } = costHeadline(
{ ...numbers.uk.higherRate.response, donation_amount: 1_000 },
"GBP",
2026,
);
expect(headline).toContain("worth £1,250 to a charity");
});

it("quotes the rules it states", () => {
renderFaq();
const text = answer("What changed for 2026?");
expect(text).toContain("up to $1,000 of cash gifts, or $2,000 on a joint return.");
expect(text).toContain("above 0.5% of their adjusted gross income");
expect(text).toContain(
"reaches the top 37% bracket give up 2/37 of their itemized deductions, up to 2/37 of their income in that bracket.",
);
expect(answer("How does Gift Aid work in GiveCalc?")).toContain(
"tapers the personal allowance above £100,000",
);
expect(answer("How does Gift Aid work in GiveCalc?")).toContain(
"where relief starts at the 21% intermediate rate",
);
});

it("links each US example to the calculator while it offers the year", () => {
const region = renderFaq();
// Links show only while the calculator offers the example's year.
const links = within(region).queryAllByRole("link", { name: /example/ });
for (const link of links) {
expect(link.getAttribute("href")).toMatch(/^\?amount=\d+&income=\d+&state=[A-Z]{2}&/);
}
const form = US_FAQ_CASES.california;
expect(exampleHref(form, [2025, 2026, 2027])).toBe(`?${toSearchParams(form)}`);
expect(exampleHref(form, [2027, 2028, 2029])).toBeNull();
});

it("links to the public code", () => {
const region = renderFaq();
const hrefs = within(region)
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(hrefs).toEqual(
expect.arrayContaining([
"https://github.com/PolicyEngine/givecalc",
"https://github.com/PolicyEngine/policyengine-us",
"https://github.com/PolicyEngine/policyengine-uk",
]),
);
});
});
52 changes: 52 additions & 0 deletions frontend/src/components/faq/Faq.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Questions and answers below the calculator. Each answer opens in place,
* with native <details> elements, so keyboards and screen readers get a
* disclosure button for every question.
*/

import { FAQ_ITEMS } from "./questions";

export const FAQ_HEADING_ID = "questions";

export default function Faq() {
return (
<section
aria-labelledby={FAQ_HEADING_ID}
className="bg-white rounded-lg shadow-md p-6"
>
<h2
id={FAQ_HEADING_ID}
// Clears the sticky PolicyEngine header when a link jumps here.
className="text-lg font-semibold text-gray-900 scroll-mt-24"
>
Questions
</h2>
<div className="mt-2 divide-y divide-gray-100">
{FAQ_ITEMS.map(({ id, question, answer }) => (
<details key={id} id={`faq-${id}`} className="group py-1 scroll-mt-24">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 rounded-md py-3 text-left font-medium text-gray-900 hover:text-primary-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 [&::-webkit-details-marker]:hidden">
<span>{question}</span>
<svg
aria-hidden="true"
className="h-5 w-5 flex-shrink-0 text-gray-500 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</summary>
<div className="pb-4 space-y-3 text-sm leading-relaxed text-gray-600">
{answer}
</div>
</details>
))}
</div>
</section>
);
}
Loading
Loading