diff --git a/src/components/Calculator.test.ts b/src/components/Calculator.test.ts deleted file mode 100644 index 6c9365c..0000000 --- a/src/components/Calculator.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { formatUSD, formatPerc, computeEvenDollarAmounts } from "./Calculator"; - -import { describe, expect, it } from "vitest"; - -describe("formatUSD", () => { - it("should format a number as USD with cents", () => { - expect(formatUSD(1)).toBe("$1.00"); - expect(formatUSD(1.5)).toBe("$1.50"); - expect(formatUSD(1.505)).toBe("$1.51"); - }); - - it("should format a number as USD without cents", () => { - expect(formatUSD(1, false)).toBe("$1"); - expect(formatUSD(1.499, false)).toBe("$1"); - expect(formatUSD(1.505, false)).toBe("$2"); - }); - - it("should format a number as USD with cents and commas", () => { - expect(formatUSD(1000)).toBe("$1,000.00"); - expect(formatUSD(1000.5)).toBe("$1,000.50"); - expect(formatUSD(1000.505)).toBe("$1,000.51"); - }); - - it("should format without a dollar sign if requested", () => { - expect(formatUSD(1, true, false)).toBe("1.00"); - expect(formatUSD(1.5, true, false)).toBe("1.50"); - expect(formatUSD(1.505, true, false)).toBe("1.51"); - }); -}); - -describe("formatPerc", () => { - it("should format a number as a percentage", () => { - expect(formatPerc(1)).toBe("100%"); - expect(formatPerc(1.5)).toBe("150%"); - expect(formatPerc(0.02)).toBe("2%"); - }); -}); - -describe("computeEvenDollarAmounts", () => { - it("Gives 100% to the only available allocation", () => { - const percs = [1.0]; - const result = computeEvenDollarAmounts(percs, 50); - expect(result).toEqual([50]); - }); - - it("Gives 50% to each of two allocations", () => { - const percs = [0.5, 0.5]; - const result = computeEvenDollarAmounts(percs, 50); - expect(result).toEqual([25, 25]); - }); - - it("Gives evenly to four allocations", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 50); - expect(result).toEqual([20, 10, 10, 10]); - }); - - it("Rounds total cents down", () => { - const percs = [1.0]; - const result = computeEvenDollarAmounts(percs, 50.95); - expect(result).toEqual([50]); - }); - - it("Adjusts larger categories down by $2 if the sum doesn't match the total", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 23); - expect(result).toEqual([8, 5, 5, 5]); - }); - - it("Adjusts larger categories down by $1 if the sum doesn't match the total", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 24); - expect(result).toEqual([9, 5, 5, 5]); - }); - - it("Evenly distributes across several categories", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 25); - expect(result).toEqual([10, 5, 5, 5]); - }); - - it("Adjusts larger categories up by $1 if the sum doesn't match the total", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 26); - expect(result).toEqual([11, 5, 5, 5]); - }); - - it("Adjusts larger categories up by $2 if the sum doesn't match the total", () => { - const percs = [0.4, 0.2, 0.2, 0.2]; - const result = computeEvenDollarAmounts(percs, 27); - expect(result).toEqual([12, 5, 5, 5]); - }); -}); diff --git a/src/components/Calculator.tsx b/src/components/Calculator.tsx deleted file mode 100644 index 76fbe91..0000000 --- a/src/components/Calculator.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { useEffect, useState, useCallback } from "react"; -import type { ReactNode, MouseEvent } from "react"; - -import { P21, P28, Em28, H2 } from "./Typeography"; - -/** Format dollars as dollars, with commas. */ -export const formatUSD = ( - usd: number, - showCents: boolean = true, - showDollarSign: boolean = true -) => { - const formatted = usd.toLocaleString("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: showCents ? 2 : 0, - maximumFractionDigits: showCents ? 2 : 0, - }); - return showDollarSign ? formatted : formatted.slice(1); -}; - -/** Format a percentage (from 0.0 to 1.0) in human-readable form. */ -export const formatPerc = (perc: number) => `${Math.round(perc * 100)}%`; - -/** A partial allocation of a total election-cycle donation. */ -interface Allocation { - name: string; - description: ReactNode; - perc: number; - url: (usd: number) => string; -} - -/** The type of a distribution. */ -type Distribution = Allocation[]; - -/** The suggested distribution of donations for the 2024 election cycle. */ -const DISTRIBUTION: Distribution = [ - { - name: "Voter Turnout", - description: ( - <> - - The most quantitatively measurable and cost-effective tactics for - voter registration, persuasion, and turnout. These are 501(c)3 - tax-deductible donations. - - - We’ve chosen the{" "} - - Movement Voter Project - {" "} - and{" "} - - Working America - - . - - - ), - perc: 0.4, - url: (usd: number) => - `https://secure.actblue.com/donate/voter-turnout-vs-trump?amount=${usd}`, - }, - { - name: "Community Specific", - description: ( - <> - - These organizations build long-term relationships with specific - communities and demographics. They are trusted messengers in critical - battleground states. - - - We’ve chosen{" "} - - Somos Votantes - - ,{" "} - - Black Leaders Organizing Communities - - , the{" "} - - New Georgia Project - - , and{" "} - - LUCHA Arizona - - . - - - ), - perc: 0.2, - url: (usd: number) => - `https://secure.actblue.com/donate/community-specific-vs-trump?amount=${usd}`, - }, - { - name: "Biden Campaign", - description: ( - - Donating to the Biden campaign is impactful because they know the most - important voters to target with get-out-the-vote and advertising. - - ), - perc: 0.2, - url: (usd: number) => - `https://secure.actblue.com/donate/biden-vs-trump?amount=${usd}`, - }, - { - name: "Competitive House Races", - description: ( - <> - - Good house candidates help turn out votes for Biden. Winning the house - a good strategy in case Trump wins. - - - We've chosen the 10 most impactful house races according to{" "} - - Swing Left - - . - - - ), - perc: 0.2, - url: (usd: number) => - `https://secure.actblue.com/donate/competitive-house-races-vs-trump?amount=${usd}`, - }, -]; - -/** React component that displays a single Allocation. */ -const AllocationComponent = ({ - allocation, - usd, - index, -}: { - allocation: Allocation; - usd: number; - index: number; -}) => { - const handleDonateClick = useCallback( - (e: MouseEvent) => { - e.preventDefault(); - - window.gtag("event", "click_donate", { - event_category: "donation", - event_label: "Clicked a donation link", - allocation: allocation.name, - usd: usd, - url: allocation.url(usd), - }); - - // ideally we'd wait a moment for the gtag event to fire, or even - // use the `event_callback` parameter to wait for it to fire, but - // given the constraints of making it work no matter the condition - // of the gtag scripts, let's just do this instead for now? - - window.open(allocation.url(usd), "_blank"); - }, - [allocation, usd] - ); - - return ( -
- - {index}. - -
- - - {allocation.name} — {formatPerc(allocation.perc)} - - -
- {allocation.description} -
-
-
- - Donate {formatUSD(usd, false, true)} - -
-
- ); -}; - -/** Compute a dollar-rounded amount for each allocation in a distribtion. */ -export const computeEvenDollarAmounts = ( - percs: number[], - usd: number -): number[] => { - // Compute the *total* number of USD we want to distribute over - // our allocations. - const totalUSD = Math.floor(usd); - - // Assert that our percentages are ordered from greatest to least. - // Also assert that they sum to 1. - let lastPerc = 1; - let sum = 0; - for (const perc of percs) { - if (perc > lastPerc) { - throw new Error( - "Allocation percentages must be in reverse order (greatest to least)" - ); - } - lastPerc = perc; - sum += perc; - } - if (sum !== 1) { - throw new Error("Allocation percentages must sum to 1"); - } - - // Distribute the USD over our allocations, rounding each allocation - // to the nearest dollar. - const evenDollarAmounts: number[] = []; - let remainingUSD = totalUSD; - for (const perc of percs) { - const roundedUSD = Math.round(perc * totalUSD); - evenDollarAmounts.push(roundedUSD); - remainingUSD -= roundedUSD; - } - - // Now, check to make sure the total dollar amount we've distributed - // matches the total USD we wanted to distribute. If it's off, we need - // to adjust the allocations. Start by adjusting the larger allocations - // up/down by one dollar, then the smaller allocations, etc. - let i = 0; - while (remainingUSD !== 0) { - const roundedUSD = evenDollarAmounts[i]; - const delta = remainingUSD > 0 ? 1 : -1; - evenDollarAmounts[i] = roundedUSD + delta; - remainingUSD -= delta; - i++; - } - - // Assert that we've distributed the correct amount of USD by explicitly - // summing the rounded amounts. - const actualUSD = evenDollarAmounts.reduce( - (sum, roundedUSD) => sum + roundedUSD, - 0 - ); - if (actualUSD !== totalUSD) { - throw new Error( - `Expected to distribute ${totalUSD} USD, but actually distributed ${actualUSD} USD` - ); - } - - // Return the final dollar amounts. - return evenDollarAmounts; -}; - -/** React component that displays a full Distribution. */ -const DistributionComponent = ({ - distribution, - usd, -}: { - distribution: Distribution; - usd: number; -}) => { - const dollarAmounts = computeEvenDollarAmounts( - distribution.map((allocation) => allocation.perc), - usd - ); - - return ( -
- {distribution.map((allocation, i) => ( - - ))} -
- ); -}; - -/** Donation amount react box. */ -const DonationAmountBox = ({ - usd, - setUSD, -}: { - usd: number; - setUSD: (usd: number) => void; -}) => { - const [text, setText] = useState(formatUSD(usd, false, false)); - - useEffect(() => { - // Copy the text for us to process - let decimalPlaces = 0; - - // is there a decimal point? - const hasDecimal = text.indexOf(".") !== -1; - - // if there *is* a decimal point, make sure there are exactly two digits after it - if (hasDecimal) { - const parts = text.split("."); - if (parts.length !== 2) { - return; - } - - decimalPlaces = parts[1].length; - } - - // remove all non-numeric characters - const cleaned = text.replace(/[^0-9]/g, ""); - - // if it's empty, bail - if (cleaned === "") { - return; - } - - // parse an integer - const parsed = parseInt(cleaned); - - // if it's not a number, bail - if (isNaN(parsed)) { - return; - } - - // convert to USD (using hasDecimal) - const cents = (parsed / Math.pow(10, decimalPlaces)) * 100; - const newUSD = cents / 100; - setUSD(newUSD); - }, [text]); - - return ( -
-

- Your donation amount -

-
-
$
-
- setText(e.target.value)} - className="text-right caret-sun bg-transparent pr-4 outline-none transition-colors duration-200 leading-10 focus:bg-dark w-[100%] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> -
-
-
- ); -}; - -/** React component that displays a calculator. */ -const Calculator = () => { - const [usd, setUSD] = useState(5000); - return ( -
-

Make your donation

- Enter the amount you’d like to donate: - - -
- ); -}; - -/** Export the Calculator component. */ -export default Calculator;