From a8a6f6bcc8acd4ae8a75e435277f48f354a17154 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 30 Nov 2023 17:22:33 -0800 Subject: [PATCH] Work in progress... --- package-lock.json | 9 ++++ package.json | 1 + server/utils/format.py | 2 +- server/web.py | 15 +++++- src/App.tsx | 120 ++++++++++++++++++++++++++++++++++++++--- 5 files changed, 139 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a645d2..f34c0f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1697,6 +1698,14 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", diff --git a/package.json b/package.json index 955403a..c4ac158 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "preview": "vite preview" }, "dependencies": { + "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/server/utils/format.py b/server/utils/format.py index 59f1f26..ca3202a 100644 --- a/server/utils/format.py +++ b/server/utils/format.py @@ -1,3 +1,3 @@ def fmt_usd(cents: int) -> str: """Format a value in cents as USD.""" - return f"${cents / 100:,.2f}" + return f"${cents / 100:,.0f}" diff --git a/server/web.py b/server/web.py index 3da3332..496c785 100644 --- a/server/web.py +++ b/server/web.py @@ -8,6 +8,7 @@ from litestar.params import Body from server.data.contacts.abbu import ZipABBUManager +from server.data.contacts.google import GoogleContactExportManager from server.data.manager import DataManager from server.data.search import ContactContributionSearcher @@ -31,6 +32,14 @@ async def search( data: t.Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)] ) -> dict: """Search a collection of contacts and summarize them.""" + is_zip = data.content_type == "application/zip" + is_csv = data.content_type == "text/csv" + if not (is_zip or is_csv): + return { + "ok": False, + "message": "Invalid file type.", + "code": "invalid_file_type", + } content = await data.read() # Write to a temporary file; then pass it to the ZipABBUManager. # Be sure to clean up the temporary file when we're done. @@ -38,7 +47,11 @@ async def search( temp.write(content) temp.flush() data_manager = DataManager.default() - contact_manager = ZipABBUManager(temp.name) + contact_manager = ( + ZipABBUManager(temp.name) + if is_zip + else GoogleContactExportManager(temp.name) + ) searcher = ContactContributionSearcher(data_manager) results = list(searcher.search_and_summarize_contacts(contact_manager)) return { diff --git a/src/App.tsx b/src/App.tsx index 633ee5e..0964684 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import React, { useCallback, useState } from "react"; import "./App.css"; @@ -48,7 +49,94 @@ interface ErrorSearchResponse { } const formatPercent = (percent: number): string => - `${(percent * 100).toFixed(0)}%`; + `${(percent * 100).toFixed(1)}%`; + +const formatUSD = (cents: number): string => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cents / 100); + +const formatParty = (party: string): string => { + switch (party) { + case "DEM": + return "Democrat"; + case "REP": + return "Republican"; + case "IND": + return "Independent"; + case "OTH": + return "Other"; + case "UNK": + return "Unknown"; + default: + return party; + } +}; + +const partyBreakdownColorClassName = ( + parties: Record +): string => { + const party = Object.entries(parties).sort( + ([, a], [, b]) => b.total_cents - a.total_cents + )[0][0]; + switch (party) { + case "DEM": + return "text-blue-800"; + case "REP": + return "text-red-800"; + case "IND": + return "text-yellow-800"; + default: + return "text-gray-800"; + } +}; + +/** + * Create a copy of the `parties` structure. Count all non-UNK parties and get + * the percentage total for each. + * + * Then, take the UNK party and distribute its total with the same percentage + * breakdown as the other parties. + * + * If there is only one party, and it is UNK, leave it be. + */ +const revisePartyBreakdown = ( + parties: Record +): Record => { + const nonUnknownParties = Object.entries(parties).filter( + ([party]) => party !== "UNK" + ); + if (nonUnknownParties.length === 0) { + return parties; + } + if (nonUnknownParties.length === Object.entries(parties).length) { + return parties; + } + const totalUnknown = parties.UNK.total_cents; + const revisedParties: Record = {}; + for (const [party, summary] of nonUnknownParties) { + revisedParties[party] = { + ...summary, + total_cents: summary.total_cents + totalUnknown * summary.percent, + total_fmt: formatUSD( + summary.total_cents + totalUnknown * summary.percent + ), + }; + } + // Fix the percentages + const revisedTotal = Object.values(revisedParties).reduce( + (total, summary) => total + summary.total_cents, + 0 + ); + for (const [party, summary] of Object.entries(revisedParties)) { + revisedParties[party] = { + ...summary, + percent: summary.total_cents / revisedTotal, + }; + } + return revisedParties; +}; type SearchResponse = SuccessSearchResponse | ErrorSearchResponse; @@ -67,19 +155,39 @@ const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => (

Results

    - {results.map((result) => ( -
  • -

    + {results.map((result, i) => ( +

  • +

    {toTitleCase( `${result.contact.first_name} ${result.contact.last_name}` )}

    - {result.contact.city}, {result.contact.state} + {toTitleCase(result.contact.city)}, {result.contact.state}

    {/*

    {result.contact.phone}

    */}

    Total: {result.summary.total_fmt}

    + {/* Produce a party breakdown */}
      + {Object.entries(revisePartyBreakdown(result.summary.parties)).map( + ([party, partySummary]) => ( +
    • +

      + {formatParty(party)}: {partySummary.total_fmt} ( + {formatPercent(partySummary.percent)}) +

      +
    • + ) + )} +
    + {/*
      {Object.entries(result.summary.committees).map( ([committeeId, committee]) => (
    • @@ -90,7 +198,7 @@ const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => (
    • ) )} -
    +
*/} ))}